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>
284 lines
10 KiB
Python
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"]
|