test: add 23 unit tests for provider endpoints
Cover all 9 endpoints in providers.py: - Provider presets (list all, get by ID, not found) - Gmail credential CRUD (create, update, get, delete) - Import labels update - Gmail authorize URL (success, not configured) - Debug email (success, no credentials, injection failure) - OAuth callback (new/update cred, not configured, token exchange failure, missing access token, verification failure) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- Unit tests for provider endpoints (`test_providers.py`): 23 tests covering provider presets, Gmail credential CRUD, import labels, authorize URL, debug email, and OAuth callback
|
||||
- Unit tests for mail account endpoints (`test_mail_accounts.py`): 30 tests covering CRUD, toggle, pull-now, test connection, auto-detect, processing runs, and processing logs
|
||||
- Unit tests for authentication endpoints (`test_auth.py`): 22 tests covering register, login, Google OAuth, authorize-url, and helper functions
|
||||
|
||||
|
||||
@@ -0,0 +1,709 @@
|
||||
"""
|
||||
Unit tests for provider endpoints (backend/app/api/v1/endpoints/providers.py).
|
||||
|
||||
All tests mock the database session, auth dependencies, and external services
|
||||
so no real PostgreSQL instance or Google API access 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, GmailCredential
|
||||
from app.services.gmail_service import GmailInjectionError
|
||||
|
||||
# ── 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,
|
||||
google_id=None,
|
||||
oauth_provider=None,
|
||||
last_login_at=None,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
defaults.update(overrides)
|
||||
user = MagicMock(spec=User)
|
||||
for k, v in defaults.items():
|
||||
setattr(user, k, v)
|
||||
return user
|
||||
|
||||
|
||||
def _make_gmail_credential(**overrides) -> MagicMock:
|
||||
"""Return a MagicMock that behaves like a GmailCredential ORM instance."""
|
||||
defaults = dict(
|
||||
id=1,
|
||||
user_id=1,
|
||||
gmail_email="user@gmail.com",
|
||||
encrypted_access_token="encrypted_access",
|
||||
encrypted_refresh_token="encrypted_refresh",
|
||||
token_expiry=datetime.now(timezone.utc),
|
||||
scopes={
|
||||
"granted_scopes": ["scope1"],
|
||||
"import_label_templates": ["imported"],
|
||||
},
|
||||
is_valid=True,
|
||||
last_verified_at=datetime.now(timezone.utc),
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
import_label_templates=["imported"],
|
||||
default_import_label_templates=["{{source_email}}", "imported"],
|
||||
granted_scopes=["scope1"],
|
||||
)
|
||||
defaults.update(overrides)
|
||||
cred = MagicMock(spec=GmailCredential)
|
||||
for k, v in defaults.items():
|
||||
setattr(cred, k, v)
|
||||
return cred
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
# ── fixtures ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
return create_application()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_user():
|
||||
return _make_user()
|
||||
|
||||
|
||||
@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 auth_client(app, test_user, mock_db):
|
||||
"""AsyncClient where the caller is an authenticated active user."""
|
||||
|
||||
async def _override_user():
|
||||
return test_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 client:
|
||||
yield client
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Provider Presets
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestListProviderPresets:
|
||||
async def test_returns_all_presets(self, auth_client):
|
||||
resp = await auth_client.get("/api/v1/providers/presets")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "providers" in data
|
||||
assert len(data["providers"]) == 13
|
||||
ids = [p["id"] for p in data["providers"]]
|
||||
assert "gmail" in ids
|
||||
assert "outlook" in ids
|
||||
assert "icloud" in ids
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestGetProviderPreset:
|
||||
async def test_known_preset(self, auth_client):
|
||||
resp = await auth_client.get("/api/v1/providers/presets/gmail")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["id"] == "gmail"
|
||||
assert data["name"] == "Gmail"
|
||||
assert "gmail.com" in data["domains"]
|
||||
|
||||
async def test_unknown_preset_404(self, auth_client):
|
||||
resp = await auth_client.get("/api/v1/providers/presets/nonexistent")
|
||||
assert resp.status_code == 404
|
||||
assert "not found" in resp.json()["detail"].lower()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Save Gmail Credential (POST /gmail-credential)
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestSaveGmailCredential:
|
||||
@patch("app.api.v1.endpoints.providers.build_gmail_credential_scopes")
|
||||
@patch("app.api.v1.endpoints.providers.encrypt_credential")
|
||||
@patch("app.api.v1.endpoints.providers.GmailService")
|
||||
async def test_create_new_credential(
|
||||
self, mock_gmail_cls, mock_encrypt, mock_build_scopes, auth_client, mock_db
|
||||
):
|
||||
mock_gmail_instance = MagicMock()
|
||||
mock_gmail_instance.verify_access = AsyncMock(return_value=True)
|
||||
mock_gmail_cls.return_value = mock_gmail_instance
|
||||
mock_encrypt.return_value = "encrypted_token"
|
||||
mock_build_scopes.return_value = {
|
||||
"granted_scopes": [],
|
||||
"import_label_templates": ["{{source_email}}", "imported"],
|
||||
}
|
||||
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
async def _populate_on_refresh(obj):
|
||||
"""Simulate what the DB does after INSERT + refresh."""
|
||||
obj.id = 1
|
||||
obj.created_at = now
|
||||
obj.updated_at = now
|
||||
|
||||
mock_db.refresh = AsyncMock(side_effect=_populate_on_refresh)
|
||||
|
||||
resp = await auth_client.post(
|
||||
"/api/v1/providers/gmail-credential",
|
||||
json={
|
||||
"access_token": "test_access",
|
||||
"refresh_token": "test_refresh",
|
||||
"gmail_email": "user@gmail.com",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["gmail_email"] == "user@gmail.com"
|
||||
assert data["is_valid"] is True
|
||||
|
||||
@patch("app.api.v1.endpoints.providers.build_gmail_credential_scopes")
|
||||
@patch("app.api.v1.endpoints.providers.encrypt_credential")
|
||||
@patch("app.api.v1.endpoints.providers.GmailService")
|
||||
async def test_update_existing_credential(
|
||||
self, mock_gmail_cls, mock_encrypt, mock_build_scopes, auth_client, mock_db
|
||||
):
|
||||
mock_gmail_instance = MagicMock()
|
||||
mock_gmail_instance.verify_access = AsyncMock(return_value=True)
|
||||
mock_gmail_cls.return_value = mock_gmail_instance
|
||||
mock_encrypt.return_value = "encrypted_token"
|
||||
mock_build_scopes.return_value = {
|
||||
"granted_scopes": ["scope1"],
|
||||
"import_label_templates": ["imported"],
|
||||
}
|
||||
|
||||
existing = _make_gmail_credential()
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(existing))
|
||||
|
||||
resp = await auth_client.post(
|
||||
"/api/v1/providers/gmail-credential",
|
||||
json={
|
||||
"access_token": "new_access",
|
||||
"refresh_token": "new_refresh",
|
||||
"gmail_email": "user@gmail.com",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["gmail_email"] == "user@gmail.com"
|
||||
|
||||
@patch("app.api.v1.endpoints.providers.GmailService")
|
||||
async def test_invalid_credentials_400(self, mock_gmail_cls, auth_client, mock_db):
|
||||
mock_gmail_instance = MagicMock()
|
||||
mock_gmail_instance.verify_access = AsyncMock(return_value=False)
|
||||
mock_gmail_cls.return_value = mock_gmail_instance
|
||||
|
||||
resp = await auth_client.post(
|
||||
"/api/v1/providers/gmail-credential",
|
||||
json={
|
||||
"access_token": "bad_token",
|
||||
"refresh_token": "bad_refresh",
|
||||
"gmail_email": "user@gmail.com",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 400
|
||||
assert "invalid" in resp.json()["detail"].lower()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Get Gmail Credential (GET /gmail-credential)
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestGetGmailCredential:
|
||||
async def test_found(self, auth_client, mock_db):
|
||||
cred = _make_gmail_credential()
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(cred))
|
||||
|
||||
resp = await auth_client.get("/api/v1/providers/gmail-credential")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["gmail_email"] == "user@gmail.com"
|
||||
assert data["is_valid"] is True
|
||||
|
||||
async def test_not_found_404(self, auth_client, mock_db):
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
|
||||
|
||||
resp = await auth_client.get("/api/v1/providers/gmail-credential")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Delete Gmail Credential (DELETE /gmail-credential)
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestDeleteGmailCredential:
|
||||
async def test_delete_success(self, auth_client, mock_db):
|
||||
cred = _make_gmail_credential()
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(cred))
|
||||
|
||||
resp = await auth_client.delete("/api/v1/providers/gmail-credential")
|
||||
assert resp.status_code == 204
|
||||
mock_db.delete.assert_awaited_once_with(cred)
|
||||
mock_db.commit.assert_awaited()
|
||||
|
||||
async def test_delete_not_found_404(self, auth_client, mock_db):
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
|
||||
|
||||
resp = await auth_client.delete("/api/v1/providers/gmail-credential")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Update Import Labels (PUT /gmail-credential/labels)
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestUpdateImportLabels:
|
||||
@patch("app.api.v1.endpoints.providers.extract_granted_scopes")
|
||||
@patch("app.api.v1.endpoints.providers.build_gmail_credential_scopes")
|
||||
@patch("app.api.v1.endpoints.providers.normalize_import_label_templates")
|
||||
async def test_update_labels_success(
|
||||
self,
|
||||
mock_normalize,
|
||||
mock_build_scopes,
|
||||
mock_extract_scopes,
|
||||
auth_client,
|
||||
mock_db,
|
||||
):
|
||||
cred = _make_gmail_credential()
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(cred))
|
||||
mock_normalize.return_value = ["custom-label"]
|
||||
mock_extract_scopes.return_value = ["scope1"]
|
||||
mock_build_scopes.return_value = {
|
||||
"granted_scopes": ["scope1"],
|
||||
"import_label_templates": ["custom-label"],
|
||||
}
|
||||
|
||||
resp = await auth_client.put(
|
||||
"/api/v1/providers/gmail-credential/labels",
|
||||
json={"import_label_templates": ["custom-label"]},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["gmail_email"] == "user@gmail.com"
|
||||
|
||||
async def test_update_labels_not_found_404(self, auth_client, mock_db):
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
|
||||
|
||||
resp = await auth_client.put(
|
||||
"/api/v1/providers/gmail-credential/labels",
|
||||
json={"import_label_templates": ["test"]},
|
||||
)
|
||||
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Get Gmail Authorize URL (GET /gmail/authorize-url)
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestGetGmailAuthorizeUrl:
|
||||
@patch("app.api.v1.endpoints.providers.settings")
|
||||
async def test_success(self, mock_settings, auth_client):
|
||||
mock_settings.GOOGLE_CLIENT_ID = "test-client-id"
|
||||
|
||||
resp = await auth_client.get(
|
||||
"/api/v1/providers/gmail/authorize-url",
|
||||
params={"redirect_uri": "http://localhost/callback"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "authorization_url" in data
|
||||
assert "test-client-id" in data["authorization_url"]
|
||||
assert "redirect_uri=http" in data["authorization_url"]
|
||||
|
||||
@patch("app.api.v1.endpoints.providers.settings")
|
||||
async def test_google_not_configured_501(self, mock_settings, auth_client):
|
||||
mock_settings.GOOGLE_CLIENT_ID = None
|
||||
|
||||
resp = await auth_client.get(
|
||||
"/api/v1/providers/gmail/authorize-url",
|
||||
params={"redirect_uri": "http://localhost/callback"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 501
|
||||
assert "not configured" in resp.json()["detail"].lower()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Send Debug Email (POST /gmail/debug-email)
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestSendDebugEmail:
|
||||
@patch("app.api.v1.endpoints.providers.encrypt_credential")
|
||||
@patch("app.api.v1.endpoints.providers.decrypt_credential")
|
||||
@patch("app.api.v1.endpoints.providers.GmailService")
|
||||
async def test_success(
|
||||
self, mock_gmail_cls, mock_decrypt, mock_encrypt, auth_client, mock_db
|
||||
):
|
||||
cred = _make_gmail_credential()
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(cred))
|
||||
mock_decrypt.return_value = "decrypted_token"
|
||||
|
||||
mock_gmail_instance = MagicMock()
|
||||
mock_gmail_instance.verify_access = AsyncMock(return_value=True)
|
||||
mock_gmail_instance.inject_debug_email = AsyncMock(
|
||||
return_value={
|
||||
"message_id": "msg1",
|
||||
"thread_id": "t1",
|
||||
"label_ids": ["INBOX"],
|
||||
}
|
||||
)
|
||||
mock_gmail_instance.get_refreshed_token = MagicMock(return_value=None)
|
||||
mock_gmail_cls.return_value = mock_gmail_instance
|
||||
|
||||
resp = await auth_client.post("/api/v1/providers/gmail/debug-email")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["message_id"] == "msg1"
|
||||
assert data["thread_id"] == "t1"
|
||||
assert "INBOX" in data["label_ids"]
|
||||
|
||||
async def test_no_credentials_400(self, auth_client, mock_db):
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
|
||||
|
||||
resp = await auth_client.post("/api/v1/providers/gmail/debug-email")
|
||||
|
||||
assert resp.status_code == 400
|
||||
assert "no valid gmail credentials" in resp.json()["detail"].lower()
|
||||
|
||||
@patch("app.api.v1.endpoints.providers.decrypt_credential")
|
||||
@patch("app.api.v1.endpoints.providers.GmailService")
|
||||
async def test_injection_failure_502(
|
||||
self, mock_gmail_cls, mock_decrypt, auth_client, mock_db
|
||||
):
|
||||
cred = _make_gmail_credential()
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(cred))
|
||||
mock_decrypt.return_value = "decrypted_token"
|
||||
|
||||
mock_gmail_instance = MagicMock()
|
||||
mock_gmail_instance.inject_debug_email = AsyncMock(
|
||||
side_effect=GmailInjectionError("API error")
|
||||
)
|
||||
mock_gmail_instance.get_refreshed_token = MagicMock(return_value=None)
|
||||
mock_gmail_cls.return_value = mock_gmail_instance
|
||||
|
||||
resp = await auth_client.post("/api/v1/providers/gmail/debug-email")
|
||||
|
||||
assert resp.status_code == 502
|
||||
assert "injection failed" in resp.json()["detail"].lower()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Gmail OAuth Callback (POST /gmail/callback)
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
def _mock_httpx_context_manager(mock_http_client):
|
||||
"""Build an httpx.AsyncClient mock that works as an async context manager."""
|
||||
mock_httpx_cls = MagicMock()
|
||||
mock_httpx_cls.return_value.__aenter__ = AsyncMock(return_value=mock_http_client)
|
||||
mock_httpx_cls.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||
return mock_httpx_cls
|
||||
|
||||
|
||||
def _make_token_response(status_code=200, json_data=None):
|
||||
"""Create a mock httpx response for the token exchange."""
|
||||
resp = MagicMock()
|
||||
resp.status_code = status_code
|
||||
resp.json.return_value = json_data or {
|
||||
"access_token": "new_access_token",
|
||||
"refresh_token": "new_refresh_token",
|
||||
"expires_in": 3600,
|
||||
"scope": "openid email https://www.googleapis.com/auth/gmail.insert",
|
||||
}
|
||||
resp.text = "error" if status_code != 200 else "ok"
|
||||
return resp
|
||||
|
||||
|
||||
def _make_profile_response(status_code=200, email="user@gmail.com"):
|
||||
"""Create a mock httpx response for the userinfo endpoint."""
|
||||
resp = MagicMock()
|
||||
resp.status_code = status_code
|
||||
resp.json.return_value = {"email": email}
|
||||
return resp
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestGmailCallback:
|
||||
@patch("app.api.v1.endpoints.providers.build_gmail_credential_scopes")
|
||||
@patch("app.api.v1.endpoints.providers.encrypt_credential")
|
||||
@patch("app.api.v1.endpoints.providers.GmailService")
|
||||
@patch("app.api.v1.endpoints.providers.httpx.AsyncClient")
|
||||
@patch("app.api.v1.endpoints.providers.settings")
|
||||
async def test_new_credential_success(
|
||||
self,
|
||||
mock_settings,
|
||||
mock_httpx_cls,
|
||||
mock_gmail_cls,
|
||||
mock_encrypt,
|
||||
mock_build_scopes,
|
||||
auth_client,
|
||||
mock_db,
|
||||
):
|
||||
mock_settings.GOOGLE_CLIENT_ID = "client-id"
|
||||
mock_settings.GOOGLE_CLIENT_SECRET = "client-secret"
|
||||
|
||||
mock_http_client = AsyncMock()
|
||||
token_resp = _make_token_response()
|
||||
profile_resp = _make_profile_response()
|
||||
mock_http_client.post = AsyncMock(return_value=token_resp)
|
||||
mock_http_client.get = AsyncMock(return_value=profile_resp)
|
||||
mock_httpx_cls.return_value.__aenter__ = AsyncMock(
|
||||
return_value=mock_http_client
|
||||
)
|
||||
mock_httpx_cls.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
mock_gmail_instance = MagicMock()
|
||||
mock_gmail_instance.verify_access = AsyncMock(return_value=True)
|
||||
mock_gmail_cls.return_value = mock_gmail_instance
|
||||
|
||||
mock_encrypt.return_value = "encrypted"
|
||||
mock_build_scopes.return_value = {
|
||||
"granted_scopes": ["openid", "email"],
|
||||
"import_label_templates": ["{{source_email}}", "imported"],
|
||||
}
|
||||
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
async def _populate_on_refresh(obj):
|
||||
"""Simulate what the DB does after INSERT + refresh."""
|
||||
obj.id = 1
|
||||
obj.created_at = now
|
||||
obj.updated_at = now
|
||||
|
||||
mock_db.refresh = AsyncMock(side_effect=_populate_on_refresh)
|
||||
|
||||
resp = await auth_client.post(
|
||||
"/api/v1/providers/gmail/callback",
|
||||
json={
|
||||
"code": "auth_code",
|
||||
"redirect_uri": "http://localhost/callback",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["gmail_email"] == "user@gmail.com"
|
||||
assert data["is_valid"] is True
|
||||
|
||||
@patch("app.api.v1.endpoints.providers.build_gmail_credential_scopes")
|
||||
@patch("app.api.v1.endpoints.providers.encrypt_credential")
|
||||
@patch("app.api.v1.endpoints.providers.GmailService")
|
||||
@patch("app.api.v1.endpoints.providers.httpx.AsyncClient")
|
||||
@patch("app.api.v1.endpoints.providers.settings")
|
||||
async def test_update_existing_credential(
|
||||
self,
|
||||
mock_settings,
|
||||
mock_httpx_cls,
|
||||
mock_gmail_cls,
|
||||
mock_encrypt,
|
||||
mock_build_scopes,
|
||||
auth_client,
|
||||
mock_db,
|
||||
):
|
||||
mock_settings.GOOGLE_CLIENT_ID = "client-id"
|
||||
mock_settings.GOOGLE_CLIENT_SECRET = "client-secret"
|
||||
|
||||
mock_http_client = AsyncMock()
|
||||
token_resp = _make_token_response()
|
||||
profile_resp = _make_profile_response()
|
||||
mock_http_client.post = AsyncMock(return_value=token_resp)
|
||||
mock_http_client.get = AsyncMock(return_value=profile_resp)
|
||||
mock_httpx_cls.return_value.__aenter__ = AsyncMock(
|
||||
return_value=mock_http_client
|
||||
)
|
||||
mock_httpx_cls.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
mock_gmail_instance = MagicMock()
|
||||
mock_gmail_instance.verify_access = AsyncMock(return_value=True)
|
||||
mock_gmail_cls.return_value = mock_gmail_instance
|
||||
|
||||
mock_encrypt.return_value = "encrypted"
|
||||
mock_build_scopes.return_value = {
|
||||
"granted_scopes": ["openid"],
|
||||
"import_label_templates": ["imported"],
|
||||
}
|
||||
|
||||
existing = _make_gmail_credential()
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(existing))
|
||||
|
||||
resp = await auth_client.post(
|
||||
"/api/v1/providers/gmail/callback",
|
||||
json={
|
||||
"code": "auth_code",
|
||||
"redirect_uri": "http://localhost/callback",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["gmail_email"] == "user@gmail.com"
|
||||
|
||||
@patch("app.api.v1.endpoints.providers.settings")
|
||||
async def test_google_not_configured_501(self, mock_settings, auth_client):
|
||||
mock_settings.GOOGLE_CLIENT_ID = None
|
||||
mock_settings.GOOGLE_CLIENT_SECRET = None
|
||||
|
||||
resp = await auth_client.post(
|
||||
"/api/v1/providers/gmail/callback",
|
||||
json={
|
||||
"code": "auth_code",
|
||||
"redirect_uri": "http://localhost/callback",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 501
|
||||
assert "not configured" in resp.json()["detail"].lower()
|
||||
|
||||
@patch("app.api.v1.endpoints.providers.httpx.AsyncClient")
|
||||
@patch("app.api.v1.endpoints.providers.settings")
|
||||
async def test_token_exchange_fails_400(
|
||||
self, mock_settings, mock_httpx_cls, auth_client
|
||||
):
|
||||
mock_settings.GOOGLE_CLIENT_ID = "client-id"
|
||||
mock_settings.GOOGLE_CLIENT_SECRET = "client-secret"
|
||||
|
||||
mock_http_client = AsyncMock()
|
||||
mock_http_client.post = AsyncMock(
|
||||
return_value=_make_token_response(status_code=400, json_data={})
|
||||
)
|
||||
mock_httpx_cls.return_value.__aenter__ = AsyncMock(
|
||||
return_value=mock_http_client
|
||||
)
|
||||
mock_httpx_cls.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
resp = await auth_client.post(
|
||||
"/api/v1/providers/gmail/callback",
|
||||
json={
|
||||
"code": "bad_code",
|
||||
"redirect_uri": "http://localhost/callback",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 400
|
||||
assert "exchange" in resp.json()["detail"].lower()
|
||||
|
||||
@patch("app.api.v1.endpoints.providers.httpx.AsyncClient")
|
||||
@patch("app.api.v1.endpoints.providers.settings")
|
||||
async def test_no_access_token_returned_400(
|
||||
self, mock_settings, mock_httpx_cls, auth_client
|
||||
):
|
||||
mock_settings.GOOGLE_CLIENT_ID = "client-id"
|
||||
mock_settings.GOOGLE_CLIENT_SECRET = "client-secret"
|
||||
|
||||
mock_http_client = AsyncMock()
|
||||
# Token response OK but missing access_token
|
||||
token_resp = _make_token_response(
|
||||
json_data={"refresh_token": "rt", "expires_in": 3600}
|
||||
)
|
||||
mock_http_client.post = AsyncMock(return_value=token_resp)
|
||||
mock_httpx_cls.return_value.__aenter__ = AsyncMock(
|
||||
return_value=mock_http_client
|
||||
)
|
||||
mock_httpx_cls.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
resp = await auth_client.post(
|
||||
"/api/v1/providers/gmail/callback",
|
||||
json={
|
||||
"code": "auth_code",
|
||||
"redirect_uri": "http://localhost/callback",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 400
|
||||
assert "access token" in resp.json()["detail"].lower()
|
||||
|
||||
@patch("app.api.v1.endpoints.providers.GmailService")
|
||||
@patch("app.api.v1.endpoints.providers.httpx.AsyncClient")
|
||||
@patch("app.api.v1.endpoints.providers.settings")
|
||||
async def test_verification_fails_400(
|
||||
self, mock_settings, mock_httpx_cls, mock_gmail_cls, auth_client
|
||||
):
|
||||
mock_settings.GOOGLE_CLIENT_ID = "client-id"
|
||||
mock_settings.GOOGLE_CLIENT_SECRET = "client-secret"
|
||||
|
||||
mock_http_client = AsyncMock()
|
||||
mock_http_client.post = AsyncMock(return_value=_make_token_response())
|
||||
mock_http_client.get = AsyncMock(return_value=_make_profile_response())
|
||||
mock_httpx_cls.return_value.__aenter__ = AsyncMock(
|
||||
return_value=mock_http_client
|
||||
)
|
||||
mock_httpx_cls.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
mock_gmail_instance = MagicMock()
|
||||
mock_gmail_instance.verify_access = AsyncMock(return_value=False)
|
||||
mock_gmail_cls.return_value = mock_gmail_instance
|
||||
|
||||
resp = await auth_client.post(
|
||||
"/api/v1/providers/gmail/callback",
|
||||
json={
|
||||
"code": "auth_code",
|
||||
"redirect_uri": "http://localhost/callback",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 400
|
||||
assert "verify" in resp.json()["detail"].lower()
|
||||
@@ -124,6 +124,7 @@ Comprehensive task breakdown for repository improvements and production readines
|
||||
### In Progress 🔨
|
||||
- [x] Write unit tests for authentication (22 tests covering register, login, Google OAuth, authorize-url, and helper functions)
|
||||
- [x] Write unit tests for mail account endpoints (30 tests covering CRUD, toggle, pull-now, test connection, auto-detect, processing runs/logs)
|
||||
- [x] Write unit tests for provider endpoints (23 tests covering presets, Gmail credential CRUD, import labels, authorize URL, debug email, OAuth callback)
|
||||
- [ ] Write unit tests for mail processing
|
||||
- [ ] Write integration tests for API endpoints
|
||||
|
||||
|
||||
Reference in New Issue
Block a user