"""Saved searches CRUD — stored queries shown as virtual mailboxes (SPEC §12 M2).""" from __future__ import annotations import uuid from collections.abc import AsyncIterator from dataclasses import dataclass import httpx import pytest_asyncio from httpx import ASGITransport from sqlalchemy import delete from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from sqlalchemy.pool import NullPool from app.config import get_settings from app.identity.passwords import hash_password from app.identity.sessions import create_session from app.main import create_app from app.models import User CSRF = "csrf" @dataclass class Ctx: client: httpx.AsyncClient user_id: uuid.UUID def _hdr() -> dict[str, str]: return {"X-CSRF": CSRF} @pytest_asyncio.fixture async def ctx() -> AsyncIterator[Ctx]: engine = create_async_engine(get_settings().database_url, poolclass=NullPool) sm = async_sessionmaker(engine, expire_on_commit=False) session = sm() user = User(handle=f"ss{uuid.uuid4().hex[:8]}@test.local", password_hash=hash_password("pw")) session.add(user) await session.commit() token = await create_session(user.id, "pytest") client = httpx.AsyncClient( transport=ASGITransport(app=create_app()), base_url="http://testserver", cookies={"session": token, "csrf": CSRF}, ) try: yield Ctx(client=client, user_id=user.id) finally: await client.aclose() await session.execute(delete(User).where(User.id == user.id)) await session.commit() await session.close() await engine.dispose() async def test_saved_search_crud_and_upsert(ctx: Ctx) -> None: # Empty to start. resp = await ctx.client.get("/api/saved-searches") assert resp.status_code == 200 assert resp.json() == [] # Create. resp = await ctx.client.post( "/api/saved-searches", json={"name": "Unread from boss", "query": "is:unread from:boss"}, headers=_hdr(), ) assert resp.status_code == 200 created = resp.json() assert created["name"] == "Unread from boss" assert created["query"] == "is:unread from:boss" assert created["pinned"] is True sid = created["id"] # It lists back. resp = await ctx.client.get("/api/saved-searches") assert [s["name"] for s in resp.json()] == ["Unread from boss"] # Re-saving under the same name upserts (updates query, keeps a single row). resp = await ctx.client.post( "/api/saved-searches", json={"name": "Unread from boss", "query": "is:unread from:ceo"}, headers=_hdr(), ) assert resp.status_code == 200 assert resp.json()["id"] == sid # same row assert resp.json()["query"] == "is:unread from:ceo" rows = (await ctx.client.get("/api/saved-searches")).json() assert len(rows) == 1 and rows[0]["query"] == "is:unread from:ceo" # Delete. resp = await ctx.client.delete(f"/api/saved-searches/{sid}", headers=_hdr()) assert resp.status_code == 200 assert (await ctx.client.get("/api/saved-searches")).json() == [] # Deleting a missing row 404s. resp = await ctx.client.delete(f"/api/saved-searches/{uuid.uuid4()}", headers=_hdr()) assert resp.status_code == 404 async def test_saved_search_requires_csrf(ctx: Ctx) -> None: # No CSRF header -> rejected (state-changing route). resp = await ctx.client.post("/api/saved-searches", json={"name": "x", "query": "y"}) assert resp.status_code == 403 async def test_saved_search_is_per_user(ctx: Ctx) -> None: await ctx.client.post( "/api/saved-searches", json={"name": "Mine", "query": "in:inbox"}, headers=_hdr(), ) # A different user's client sees none of the above. other = User(handle=f"ss{uuid.uuid4().hex[:8]}@test.local", password_hash=hash_password("pw")) engine = create_async_engine(get_settings().database_url, poolclass=NullPool) sm = async_sessionmaker(engine, expire_on_commit=False) session = sm() session.add(other) await session.commit() token = await create_session(other.id, "pytest") try: client = httpx.AsyncClient( transport=ASGITransport(app=create_app()), base_url="http://testserver", cookies={"session": token, "csrf": CSRF}, ) try: assert (await client.get("/api/saved-searches")).json() == [] finally: await client.aclose() finally: await session.execute(delete(User).where(User.id == other.id)) await session.commit() await session.close() await engine.dispose()