Files
webmail-client/backend/tests/sync/test_secondary_poll.py
T

320 lines
11 KiB
Python
Raw Normal View History

"""Secondary (non-INBOX) folder polling — multi-folder CONDSTORE sync (SPEC §6.3)."""
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from email.message import EmailMessage
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.config import get_settings
from app.db import session_scope
from app.identity.passwords import hash_password
from app.models import Account, Draft, Mailbox, Message, User
from app.providers.base import MailboxDelta, RemoteMessage
from app.sync import engine as sync_engine
def _raw(subject: str, mid: str) -> bytes:
m = EmailMessage()
m["Subject"] = subject
m["From"] = "me@example.com"
m["To"] = "bob@example.com"
m["Message-ID"] = mid
m["Date"] = "Mon, 09 Mar 2026 08:36:37 +0100"
m.set_content("body")
return m.as_bytes()
async def test_secondary_poll_syncs_special_folders_not_inbox(monkeypatch) -> None: # type: ignore[no-untyped-def]
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
sm = async_sessionmaker(engine, expire_on_commit=False)
session = sm()
user = User(handle=f"sec{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()
inbox = Mailbox(account_id=account.id, user_id=user.id, path="INBOX", special_use="inbox")
sent = Mailbox(account_id=account.id, user_id=user.id, path="SENT", special_use="sent")
custom = Mailbox(account_id=account.id, user_id=user.id, path="Notes", special_use=None)
session.add_all([inbox, sent, custom])
await session.commit()
polled: list[str] = []
class FakeProvider:
async def poll(self, mailbox: str, *, last_seen_uid: int, highestmodseq: int | None): # type: ignore[no-untyped-def]
polled.append(mailbox)
if mailbox == "SENT":
return MailboxDelta(
[
RemoteMessage(
uid=5,
raw=_raw("Sent one", "<s1@x.com>"),
flags=frozenset({"\\Seen"}),
internal_date=datetime.now(UTC),
)
],
[],
[],
None,
None,
)
return MailboxDelta([], [], [], None, None)
async def close(self) -> None:
return None
monkeypatch.setattr(sync_engine, "build_provider", lambda _a: FakeProvider())
try:
await sync_engine._poll_secondary_once(account.id)
# Only the non-INBOX special-use folder is polled (SENT); INBOX and the
# custom folder (no special_use) are skipped.
assert polled == ["SENT"]
# The delta was applied: the sent message is now in the DB under SENT.
session.expunge_all()
n = await session.scalar(
select(func.count()).select_from(Message).where(Message.mailbox_id == sent.id)
)
assert n == 1
finally:
async with session_scope() as cleanup:
await cleanup.execute(delete(User).where(User.id == user.id))
await session.close()
await engine.dispose()
async def test_drafts_poll_reconciles_expunged_rows(monkeypatch) -> None: # type: ignore[no-untyped-def]
"""A Drafts poll drops DB rows whose server copy was expunged (phantoms) and
un-links a local draft whose mirrored copy vanished."""
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
sm = async_sessionmaker(engine, expire_on_commit=False)
session = sm()
user = User(handle=f"rec{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()
drafts = Mailbox(account_id=account.id, user_id=user.id, path="DRAFTS", special_use="drafts")
session.add(drafts)
await session.flush()
# uid 2 survives on the server; uids 1 and 3 were expunged (phantom DB rows).
for uid in (1, 2, 3):
session.add(
Message(
user_id=user.id,
account_id=account.id,
mailbox_id=drafts.id,
uid=uid,
internal_date=datetime.now(UTC),
blob_sha256="",
)
)
# A local draft still linking the vanished uid 3 must be un-linked (imap_uid -> NULL).
linked = Draft(user_id=user.id, account_id=account.id, kind="new", payload={}, imap_uid=3)
kept = Draft(user_id=user.id, account_id=account.id, kind="new", payload={}, imap_uid=2)
session.add_all([linked, kept])
await session.commit()
class FakeProvider:
async def poll(self, mailbox: str, *, last_seen_uid: int, highestmodseq: int | None): # type: ignore[no-untyped-def]
return MailboxDelta([], [], [], None, None)
async def list_uids(self, mailbox: str) -> set[int]:
return {2} # only uid 2 remains on the server
async def close(self) -> None:
return None
monkeypatch.setattr(sync_engine, "build_provider", lambda _a: FakeProvider())
try:
await sync_engine._poll_secondary_once(account.id)
session.expunge_all()
remaining = set(
await session.scalars(select(Message.uid).where(Message.mailbox_id == drafts.id))
)
assert remaining == {2} # phantoms 1 and 3 removed
assert (await session.get(Draft, linked.id)).imap_uid is None # un-linked
assert (await session.get(Draft, kept.id)).imap_uid == 2 # untouched
finally:
async with session_scope() as cleanup:
await cleanup.execute(delete(User).where(User.id == user.id))
await session.close()
await engine.dispose()
async def test_apply_delta_expunges_vanished_uids() -> None: # type: ignore[no-untyped-def]
"""A QRESYNC delta carrying ``expunged_uids`` removes those rows, drops them from
the search index (tsvector cleared), and un-links any local draft that mirrored
a vanished UID — the VANISHED path (SPEC §6.3)."""
from app.models import Message as MessageModel
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
sm = async_sessionmaker(engine, expire_on_commit=False)
session = sm()
user = User(handle=f"van{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()
inbox = Mailbox(
account_id=account.id,
user_id=user.id,
path="INBOX",
special_use="inbox",
last_seen_uid=10,
)
session.add(inbox)
await session.flush()
for uid in (7, 8, 9):
session.add(
MessageModel(
user_id=user.id,
account_id=account.id,
mailbox_id=inbox.id,
uid=uid,
internal_date=datetime.now(UTC),
blob_sha256="",
)
)
linked = Draft(user_id=user.id, account_id=account.id, kind="new", payload={}, imap_uid=9)
session.add(linked)
await session.commit()
class FakeProvider:
qresync_enabled = True
async def close(self) -> None:
return None
# uids 7 and 9 vanished on the server; 8 survives.
delta = MailboxDelta([], [], [7, 9], None, 4242)
await sync_engine.apply_delta(FakeProvider(), account, inbox, delta) # type: ignore[arg-type]
try:
session.expunge_all()
remaining = set(
await session.scalars(
select(MessageModel.uid).where(MessageModel.mailbox_id == inbox.id)
)
)
assert remaining == {8} # 7 and 9 expunged
assert (await session.get(Draft, linked.id)).imap_uid is None # un-linked
refreshed = await session.get(Mailbox, inbox.id)
assert refreshed.highestmodseq == 4242 # modseq baseline advanced
finally:
async with session_scope() as cleanup:
await cleanup.execute(delete(User).where(User.id == user.id))
await session.close()
await engine.dispose()
async def test_server_side_flag_change_updates_the_thread_counters() -> None:
"""A flag set elsewhere (phone, webmail, server rule) must re-denormalize.
`threads.unread_count` is what the message list renders, while the reading
pane renders the message's own flags. When a delta updated flags without
recomputing the thread, the two disagreed forever: the list kept showing a
message as unread that the pane showed as read, and only a manual "mark as
read" — which goes through a different path — reconciled them.
"""
from app.models import Thread
from app.threading_.threader import denormalize_thread
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
sm = async_sessionmaker(engine, expire_on_commit=False)
session = sm()
user = User(handle=f"fc{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()
inbox = Mailbox(
account_id=account.id, user_id=user.id, path="INBOX", special_use="inbox", last_seen_uid=5
)
thread = Thread(user_id=user.id, subject_norm="t", last_message_at=datetime.now(UTC))
session.add_all([inbox, thread])
await session.flush()
message = Message(
user_id=user.id,
account_id=account.id,
mailbox_id=inbox.id,
thread_id=thread.id,
uid=5,
internal_date=datetime.now(UTC),
blob_sha256="0" * 64,
flags=[],
)
session.add(message)
await session.flush()
await denormalize_thread(session, thread.id)
await session.commit()
assert (await session.get(Thread, thread.id)).unread_count == 1
class FakeProvider:
qresync_enabled = True
async def close(self) -> None:
return None
class _FlagChange:
uid = 5
flags = ["\\Seen"]
delta = MailboxDelta([], [_FlagChange()], [], None, None)
await sync_engine.apply_delta(FakeProvider(), account, inbox, delta) # type: ignore[arg-type]
try:
session.expunge_all()
refreshed = await session.get(Thread, thread.id)
assert refreshed.unread_count == 0, (
"the server said the message was read; the thread counter did not follow"
)
finally:
async with session_scope() as cleanup:
await cleanup.execute(delete(User).where(User.id == user.id))
await session.close()
await engine.dispose()