"""Tests for cloud scout CRUD routes.""" from __future__ import annotations import time import uuid from unittest.mock import AsyncMock, patch import pytest from httpx import ASGITransport, AsyncClient from sqlalchemy import select from app.db import get_session from app.integrations import encrypt_token from app.main import app from app.models import CloudScoutConfig from tests.conftest import _TestSessionLocal, make_jwt, TEST_USER_IDS def _auth_headers(tier: str = "power") -> dict: return {"Authorization": f"Bearer {make_jwt(tier)}"} async def _test_get_session(): async with _TestSessionLocal() as session: yield session @pytest.fixture(autouse=True) def _override_session(): # FastAPI resolves Depends() by the original function object, so patching the # module-level name does not take effect — use dependency_overrides instead. app.dependency_overrides[get_session] = _test_get_session yield app.dependency_overrides.pop(get_session, None) @pytest.mark.asyncio async def test_create_cloud_scout_defaults_schedule(): payload = { "name": "Inbox", "provider": "gmail", "data_types": [], "prompt_template": "client requests", "auto_trash_spam": True, # schedule_cron omitted → server default } async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: resp = await client.post("/api/v1/scouts/cloud", json=payload, headers=_auth_headers()) assert resp.status_code == 201, resp.text body = resp.json() assert body["name"] == "Inbox" assert body["provider"] == "gmail" assert body["auto_trash_spam"] is True assert body["prompt_template"] == "client requests" assert body["schedule_cron"] # non-empty default applied assert body["oauth_connected"] is False assert body["gmail_address"] is None @pytest.mark.asyncio async def test_list_cloud_scouts_returns_only_own(): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: await client.post( "/api/v1/scouts/cloud", json={"name": "A", "provider": "gmail"}, headers=_auth_headers(), ) resp = await client.get("/api/v1/scouts/cloud", headers=_auth_headers()) assert resp.status_code == 200 rows = resp.json() assert all(r["provider"] == "gmail" for r in rows) assert any(r["name"] == "A" for r in rows) @pytest.mark.asyncio async def test_update_cloud_scout_applies_filter_and_autotrash(): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: created = (await client.post( "/api/v1/scouts/cloud", json={"name": "B", "provider": "gmail"}, headers=_auth_headers(), )).json() sid = created["id"] resp = await client.put( f"/api/v1/scouts/cloud/{sid}", json={"filter_config": {"labels": ["INBOX"], "senders": ["@client.co"]}, "auto_trash_spam": True, "prompt_template": "invoices"}, headers=_auth_headers(), ) assert resp.status_code == 200, resp.text body = resp.json() assert body["filter_config"] == {"labels": ["INBOX"], "senders": ["@client.co"]} assert body["auto_trash_spam"] is True assert body["prompt_template"] == "invoices" @pytest.mark.asyncio async def test_delete_cloud_scout(): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: created = (await client.post( "/api/v1/scouts/cloud", json={"name": "C", "provider": "gmail"}, headers=_auth_headers(), )).json() sid = created["id"] resp = await client.delete(f"/api/v1/scouts/cloud/{sid}", headers=_auth_headers()) assert resp.status_code == 200 listing = (await client.get("/api/v1/scouts/cloud", headers=_auth_headers())).json() assert all(r["id"] != sid for r in listing) @pytest.mark.asyncio async def test_gmail_labels_route_returns_labels(): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: created = (await client.post( "/api/v1/scouts/cloud", json={"name": "L", "provider": "gmail"}, headers=_auth_headers(), )).json() sid = created["id"] with patch("app.api.routes.scouts.get_connector") as mock_get: mock_get.return_value.list_labels = AsyncMock(return_value=[{"id": "INBOX", "name": "INBOX"}]) resp = await client.get(f"/api/v1/scouts/cloud/{sid}/gmail-labels", headers=_auth_headers()) assert resp.status_code == 200 assert resp.json() == [{"id": "INBOX", "name": "INBOX"}] @pytest.mark.asyncio async def test_gmail_disconnect_clears_token(): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: created = (await client.post( "/api/v1/scouts/cloud", json={"name": "D", "provider": "gmail"}, headers=_auth_headers(), )).json() sid = created["id"] # mark it connected directly in the DB async with _TestSessionLocal() as session: row = await session.get(CloudScoutConfig, sid) row.oauth_token_encrypted = "blob" row.gmail_address = "a@b.com" await session.commit() with patch("app.api.routes.scouts.get_connector") as mock_get: mock_get.return_value.stop_watch = AsyncMock() resp = await client.post(f"/api/v1/scouts/cloud/{sid}/gmail-disconnect", headers=_auth_headers()) assert resp.status_code == 200 body = resp.json() assert body["oauth_connected"] is False assert body["gmail_address"] is None assert body["enabled"] is False # ── Pending-session create-at-finalize flow ─────────────────────────────────── @pytest.mark.asyncio async def test_authorize_draft_returns_url_and_no_scout_created(): from app.config.settings import settings as app_settings with patch.object(app_settings, "GOOGLE_AUTH_CLIENT_ID", "cid"), \ patch.object(app_settings, "GOOGLE_AUTH_CLIENT_SECRET", "secret"): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: resp = await client.post( "/api/v1/scouts/oauth/gmail/authorize-draft", json={"name": "Draft Inbox", "prompt_template": "invoices", "auto_trash_spam": True}, headers=_auth_headers(), ) assert resp.status_code == 200, resp.text assert resp.json()["authorize_url"].startswith("https://accounts.google.com/") # No scout row should have been created by authorize-draft. async with _TestSessionLocal() as session: rows = (await session.execute( select(CloudScoutConfig).where( CloudScoutConfig.user_id == TEST_USER_IDS["power"], CloudScoutConfig.name == "Draft Inbox", ) )).scalars().all() assert rows == [] @pytest.mark.asyncio async def test_finalize_creates_scout_from_session(): from app.api.routes import scouts as scouts_mod state = "test-session-" + uuid.uuid4().hex token = encrypt_token({"token": "x", "refresh_token": "y", "client_id": "c", "client_secret": "s"}) scouts_mod._pending_scout_oauth_states[state] = { "code_verifier": "v", "user_id": TEST_USER_IDS["power"], "expires_at": time.time() + 600, "mode": "create", "scout_id": None, "draft": {"name": "Finalized", "prompt_template": "tasks", "auto_trash_spam": True}, "token_encrypted": token, "gmail_address": "me@gmail.com", } # Patch get_connector to raise KeyError so setup_watch is skipped (best-effort). with patch("app.api.routes.scouts.get_connector", side_effect=KeyError("gmail")): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: resp = await client.post( "/api/v1/scouts/cloud/finalize", json={"session": state, "filter_config": {"labels": ["INBOX"]}}, headers=_auth_headers(), ) assert resp.status_code == 201, resp.text body = resp.json() assert body["name"] == "Finalized" assert body["auto_trash_spam"] is True assert body["filter_config"] == {"labels": ["INBOX"]} assert body["gmail_address"] == "me@gmail.com" assert body["oauth_connected"] is True # Session must have been popped. assert state not in scouts_mod._pending_scout_oauth_states @pytest.mark.asyncio async def test_finalize_rejects_unknown_session(): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: resp = await client.post( "/api/v1/scouts/cloud/finalize", json={"session": "does-not-exist", "filter_config": None}, headers=_auth_headers(), ) assert resp.status_code == 401, resp.text