From 1c65bbfe75ebf6706e227ba898b2eb31d9620476 Mon Sep 17 00:00:00 2001 From: Roberto Date: Wed, 10 Jun 2026 15:29:02 +0200 Subject: [PATCH] feat(scouts): add cloud scout CRUD routes + serializer --- app/api/routes/scouts.py | 106 +++++++++++++++++++++++++++++++++ tests/test_scout_cloud_crud.py | 106 +++++++++++++++++++++++++++++++++ 2 files changed, 212 insertions(+) create mode 100644 tests/test_scout_cloud_crud.py diff --git a/app/api/routes/scouts.py b/app/api/routes/scouts.py index 30e4613..b648713 100644 --- a/app/api/routes/scouts.py +++ b/app/api/routes/scouts.py @@ -42,6 +42,9 @@ from app.db import get_session from app.integrations import encrypt_token from app.models import CloudScoutConfig, ScoutRunLog, LocalScoutConfig from app.schemas import ( + CloudScoutCreateRequest, + CloudScoutResponse, + CloudScoutUpdateRequest, ScoutCatalogItem, ScoutCreationCheckRequest, ScoutCreationCheckResponse, @@ -269,6 +272,109 @@ async def summarize_note( return NoteSummarizeResponse(summary=summary) +# ── Cloud scout CRUD ────────────────────────────────────────────────────────── + +_DEFAULT_CLOUD_SCHEDULE = "0 */6 * * *" + + +def _to_cloud_response(scout: CloudScoutConfig) -> dict: + return { + "id": scout.id, + "user_id": scout.user_id, + "provider": scout.provider, + "name": scout.name, + "data_types": scout.data_types or [], + "prompt_template": scout.prompt_template or "", + "schedule_cron": scout.schedule_cron, + "filter_config": scout.filter_config, + "auto_trash_spam": scout.auto_trash_spam, + "enabled": scout.enabled, + "last_run_at": _dt_ms_opt(scout.last_run_at), + "gmail_address": scout.gmail_address, + "oauth_connected": scout.oauth_token_encrypted is not None, + "created_at": _dt_ms(scout.created_at), + "updated_at": _dt_ms(scout.updated_at), + } + + +@router.get("/cloud", response_model=list[CloudScoutResponse]) +async def list_cloud_scouts( + db: AsyncSession = Depends(get_session), + current_user: UserProfile = Depends(get_current_user), +): + rows = (await db.execute( + select(CloudScoutConfig).where(CloudScoutConfig.user_id == current_user.id) + )).scalars().all() + return [_to_cloud_response(s) for s in rows] + + +@router.post("/cloud", response_model=CloudScoutResponse, status_code=status.HTTP_201_CREATED) +async def create_cloud_scout( + body: CloudScoutCreateRequest, + db: AsyncSession = Depends(get_session), + current_user: UserProfile = Depends(get_current_user), +): + scout = CloudScoutConfig( + id=str(uuid.uuid4()), + user_id=current_user.id, + provider=body.provider, + name=body.name, + data_types=body.data_types, + prompt_template=body.prompt_template, + filter_config=body.filter_config, + schedule_cron=body.schedule_cron or _DEFAULT_CLOUD_SCHEDULE, + auto_trash_spam=body.auto_trash_spam, + enabled=True, + ) + db.add(scout) + await db.commit() + await db.refresh(scout) + return _to_cloud_response(scout) + + +@router.put("/cloud/{scout_id}", response_model=CloudScoutResponse) +async def update_cloud_scout( + scout_id: str, + body: CloudScoutUpdateRequest, + db: AsyncSession = Depends(get_session), + current_user: UserProfile = Depends(get_current_user), +): + scout = await db.get(CloudScoutConfig, scout_id) + if scout is None or scout.user_id != current_user.id: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Scout not found") + if body.name is not None: + scout.name = body.name + if body.data_types is not None: + scout.data_types = body.data_types + if body.prompt_template is not None: + scout.prompt_template = body.prompt_template + if body.schedule_cron is not None: + scout.schedule_cron = body.schedule_cron + if body.filter_config is not None: + scout.filter_config = body.filter_config + if body.auto_trash_spam is not None: + scout.auto_trash_spam = body.auto_trash_spam + if body.enabled is not None: + scout.enabled = body.enabled + await db.commit() + await db.refresh(scout) + return _to_cloud_response(scout) + + +@router.delete("/cloud/{scout_id}") +async def delete_cloud_scout( + scout_id: str, + db: AsyncSession = Depends(get_session), + current_user: UserProfile = Depends(get_current_user), +): + scout = await db.get(CloudScoutConfig, scout_id) + if scout is None or scout.user_id != current_user.id: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Scout not found") + await db.delete(scout) + await db.commit() + return {"ok": True} + + # ── Gmail OAuth setup (scout-specific) ─────────────────────────────────────── # Scopes required for Gmail scout connectivity. diff --git a/tests/test_scout_cloud_crud.py b/tests/test_scout_cloud_crud.py new file mode 100644 index 0000000..a4f2eaf --- /dev/null +++ b/tests/test_scout_cloud_crud.py @@ -0,0 +1,106 @@ +"""Tests for cloud scout CRUD routes.""" + +from __future__ import annotations + +import uuid +from unittest.mock import AsyncMock, patch + +import pytest +from httpx import ASGITransport, AsyncClient + +from app.db import get_session +from app.main import app +from app.models import CloudScoutConfig +from tests.conftest import _TestSessionLocal, make_jwt + + +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)