Files
webmail-client/backend/tests/api/test_api.py
T
lukasandClaude Opus 4.8 a5ec0d21cb Initial commit: webmail client (M0/M1)
Three-pane webmail client.
- Backend: FastAPI + async SQLAlchemy + arq worker (Postgres/Redis), IMAP/SMTP
  sync, threading, search, drafts/outbox with undo, attachments, 2FA.
- Frontend: React 18 + Vite + TypeScript + TanStack Query + Tailwind; three-pane
  UI with rich-text composer, light/dark themes, resizable panels.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 23:20:34 +02:00

284 lines
10 KiB
Python

"""REST API integration tests (SPEC §9.1) driving the ASGI app with seeded data."""
from __future__ import annotations
import uuid
from collections.abc import AsyncIterator
from dataclasses import dataclass, field
from datetime import UTC, datetime
from email.message import EmailMessage
import httpx
import pytest_asyncio
from httpx import ASGITransport
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession, 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.indexer.indexer import persist_message
from app.main import create_app
from app.models import Account, Mailbox, Message, User
from app.providers.base import RemoteMessage
from app.search.pg import PgSearchBackend
@dataclass
class ApiCtx:
client: httpx.AsyncClient
user_id: uuid.UUID
account_id: uuid.UUID | None = None
archive_id: uuid.UUID | None = None
session: AsyncSession | None = None
thread_ids: list[uuid.UUID] = field(default_factory=list)
message_ids: list[uuid.UUID] = field(default_factory=list)
def _raw(
subject: str, body: str, *, mid: str, refs: str | None = None, html: str | None = None
) -> bytes:
m = EmailMessage()
m["Subject"] = subject
m["From"] = "Alice <alice@example.com>"
m["To"] = "me@example.com"
m["Message-ID"] = mid
m["Date"] = "Mon, 09 Mar 2026 08:36:37 +0100"
if refs:
m["References"] = refs
m["In-Reply-To"] = refs
m.set_content(body)
if html:
m.add_alternative(html, subtype="html")
return m.as_bytes()
@pytest_asyncio.fixture
async def api() -> AsyncIterator[ApiCtx]:
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
sm = async_sessionmaker(engine, expire_on_commit=False)
session = sm()
user = User(handle=f"api{uuid.uuid4().hex[:8]}@test.local", password_hash=hash_password("pw"))
session.add(user)
await session.flush()
account = Account(
user_id=user.id,
email="me@example.com",
imap_config={},
smtp_config={},
secret_ct=b"",
discovery_src="manual",
)
session.add(account)
await session.flush()
mailbox = Mailbox(account_id=account.id, user_id=user.id, path="INBOX", special_use="inbox")
archive = Mailbox(account_id=account.id, user_id=user.id, path="Archive", special_use="archive")
session.add_all([mailbox, archive])
await session.commit()
backend = PgSearchBackend()
ctx = ApiCtx(
client=None, # type: ignore[arg-type]
user_id=user.id,
account_id=account.id,
archive_id=archive.id,
session=session,
)
specs = [
(_raw("Project kickoff", "kickoff meeting notes and revenue plan", mid="<a@x.com>"), 1),
(
_raw(
"Re: Project kickoff",
"sounds good, revenue looks strong",
mid="<b@x.com>",
refs="<a@x.com>",
),
2,
),
(
_raw(
"Lunch",
"grab lunch tomorrow?",
mid="<c@x.com>",
html="<p>grab <b>lunch</b> <script>x()</script>tomorrow?</p>",
),
3,
),
]
for raw, u in specs:
remote = RemoteMessage(uid=u, raw=raw, flags=frozenset(), internal_date=datetime.now(UTC))
result = await persist_message(
session,
user_id=user.id,
account_id=account.id,
mailbox_id=mailbox.id,
mailbox_special_use="inbox",
remote=remote,
)
await session.commit()
assert result is not None
ctx.message_ids.append(result.message.id)
if result.message.thread_id not in ctx.thread_ids:
ctx.thread_ids.append(result.message.thread_id)
await backend.index(result.search_doc)
token = await create_session(user.id, "pytest")
transport = ASGITransport(app=create_app())
ctx.client = httpx.AsyncClient(
transport=transport, base_url="http://testserver", cookies={"session": token}
)
try:
yield ctx
finally:
await ctx.client.aclose()
await session.execute(delete(User).where(User.id == user.id))
await session.commit()
await session.close()
await engine.dispose()
async def test_requires_auth() -> None:
transport = ASGITransport(app=create_app())
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
resp = await client.get("/api/threads")
assert resp.status_code == 401
assert resp.json()["error"]["code"] == "not_authenticated"
async def test_list_threads(api: ApiCtx) -> None:
resp = await api.client.get("/api/threads")
assert resp.status_code == 200
data = resp.json()
# 2 threads: the kickoff reply-chain + lunch
assert len(data["threads"]) == 2
# newest first
subjects = [t["subject"] for t in data["threads"]]
assert "Lunch" in subjects
kickoff = next(t for t in data["threads"] if "kickoff" in t["subject"].lower())
assert kickoff["message_count"] == 2
async def test_folder_view(api: ApiCtx) -> None:
# all seeded mail is in an inbox mailbox
inbox = await api.client.get("/api/threads", params={"view": "folder:inbox"})
assert inbox.status_code == 200
assert len(inbox.json()["threads"]) == 2 # kickoff chain + lunch
# no messages are in a sent folder
sent = await api.client.get("/api/threads", params={"view": "folder:sent"})
assert sent.status_code == 200
assert sent.json()["threads"] == []
async def test_account_view_shows_only_that_accounts_inbox(api: ApiCtx) -> None:
assert api.session is not None
# The account view starts equal to the inbox (all seeded mail is in inbox).
resp = await api.client.get("/api/threads", params={"view": f"account:{api.account_id}"})
assert resp.status_code == 200
assert len(resp.json()["threads"]) == 2
# Move the whole "Lunch" thread out of the inbox into Archive.
lunch_id = next(t["id"] for t in resp.json()["threads"] if t["subject"] == "Lunch")
msgs = list(
await api.session.scalars(select(Message).where(Message.thread_id == uuid.UUID(lunch_id)))
)
for m in msgs:
m.mailbox_id = api.archive_id
await api.session.commit()
# It must drop out of both the unified inbox and the account view...
inbox = await api.client.get("/api/threads", params={"view": "folder:inbox"})
assert [t["subject"] for t in inbox.json()["threads"]] == ["Project kickoff"]
account = await api.client.get("/api/threads", params={"view": f"account:{api.account_id}"})
assert [t["subject"] for t in account.json()["threads"]] == ["Project kickoff"]
# ...but still be reachable under the Archive folder.
archive = await api.client.get("/api/threads", params={"view": "folder:archive"})
assert [t["subject"] for t in archive.json()["threads"]] == ["Lunch"]
async def test_attachments_list_and_zip(api: ApiCtx) -> None:
import io
import zipfile
from app.indexer import blobstore
from app.models import Attachment
assert api.session is not None
mid = api.message_ids[-1] # the "Lunch" message
sha = blobstore.store(api.user_id, b"hello attachment")
api.session.add(
Attachment(
message_id=mid,
user_id=api.user_id,
filename="notes.txt",
mime_type="text/plain",
size_bytes=16,
content_id=None,
blob_sha256=sha,
)
)
await api.session.commit()
listing = await api.client.get(f"/api/messages/{mid}/attachments")
assert listing.status_code == 200
body = listing.json()
assert [a["filename"] for a in body] == ["notes.txt"]
assert body[0]["size_bytes"] == 16
zipped = await api.client.get(f"/api/messages/{mid}/attachments.zip")
assert zipped.status_code == 200
assert zipped.headers["content-type"] == "application/zip"
assert "Attachments.zip" in zipped.headers["content-disposition"]
archive = zipfile.ZipFile(io.BytesIO(zipped.content))
assert archive.namelist() == ["notes.txt"]
assert archive.read("notes.txt") == b"hello attachment"
async def test_thread_detail_messages_ascending(api: ApiCtx) -> None:
# find the kickoff thread (2 messages)
resp = await api.client.get("/api/threads")
kickoff = next(t for t in resp.json()["threads"] if t["message_count"] == 2)
detail = await api.client.get(f"/api/threads/{kickoff['id']}")
assert detail.status_code == 200
body = detail.json()
assert body["order"] == "asc"
dates = [m["date"] for m in body["messages"]]
assert dates == sorted(dates) # ascending
async def test_message_html_is_sanitized(api: ApiCtx) -> None:
# the lunch message has HTML with a script
resp = await api.client.get("/api/threads")
lunch = next(t for t in resp.json()["threads"] if t["subject"] == "Lunch")
detail = (await api.client.get(f"/api/threads/{lunch['id']}")).json()
mid = detail["messages"][0]["id"]
html = await api.client.get(f"/api/messages/{mid}/html")
assert html.status_code == 200
assert "<script" not in html.text.lower()
assert "lunch" in html.text.lower()
async def test_search_grouped_by_thread(api: ApiCtx) -> None:
resp = await api.client.get("/api/search", params={"q": "revenue"})
assert resp.status_code == 200
data = resp.json()
assert len(data["threads"]) == 1 # both kickoff messages -> one thread
assert data["threads"][0]["thread"]["message_count"] == 2
async def test_accounts_list(api: ApiCtx) -> None:
resp = await api.client.get("/api/accounts")
assert resp.status_code == 200
assert resp.json()[0]["email"] == "me@example.com"
async def test_thread_pagination_cursor(api: ApiCtx) -> None:
first = await api.client.get("/api/threads", params={"limit": 1})
body = first.json()
assert len(body["threads"]) == 1
assert body["cursor"] is not None
second = await api.client.get("/api/threads", params={"limit": 1, "cursor": body["cursor"]})
body2 = second.json()
assert len(body2["threads"]) == 1
assert body2["threads"][0]["id"] != body["threads"][0]["id"]