105 lines
3.7 KiB
Python
105 lines
3.7 KiB
Python
"""Thread counters that drifted from their messages are repaired (nightly safety
|
|||
|
|
net, app/threading_/threader.py repair_threads)."""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import uuid
|
||
|
|
from datetime import UTC, datetime
|
||
|
|
from email.message import EmailMessage
|
||
|
|
|
||
|
|
from sqlalchemy import delete, select, update
|
||
|
|
|
||
|
|
from app.db import session_scope
|
||
|
|
from app.identity.passwords import hash_password
|
||
|
|
from app.indexer.indexer import persist_message
|
||
|
|
from app.models import Account, Draft, Mailbox, Thread, User
|
||
|
|
from app.providers.base import RemoteMessage
|
||
|
|
from app.threading_.threader import repair_threads
|
||
|
|
|
||
|
|
|
||
|
|
def _raw(subject: str) -> bytes:
|
||
|
|
m = EmailMessage()
|
||
|
|
m["Subject"] = subject
|
||
|
|
m["From"] = "billing@example.com"
|
||
|
|
m["To"] = "me@example.com"
|
||
|
|
m["Message-ID"] = f"<{uuid.uuid4().hex}@example.com>"
|
||
|
|
m.set_content("receipt")
|
||
|
|
return m.as_bytes()
|
||
|
|
|
||
|
|
|
||
|
|
async def test_repair_recounts_stale_threads_and_drops_empty_ones() -> None:
|
||
|
|
async with session_scope() as db:
|
||
|
|
user = User(
|
||
|
|
handle=f"rep{uuid.uuid4().hex[:8]}@test.local", password_hash=hash_password("pw")
|
||
|
|
)
|
||
|
|
db.add(user)
|
||
|
|
await db.flush()
|
||
|
|
account = Account(
|
||
|
|
user_id=user.id,
|
||
|
|
email="me@example.com",
|
||
|
|
imap_config={},
|
||
|
|
smtp_config={},
|
||
|
|
secret_ct=b"",
|
||
|
|
discovery_src="manual",
|
||
|
|
)
|
||
|
|
db.add(account)
|
||
|
|
await db.flush()
|
||
|
|
inbox = Mailbox(account_id=account.id, user_id=user.id, path="INBOX", special_use="inbox")
|
||
|
|
db.add(inbox)
|
||
|
|
await db.flush()
|
||
|
|
result = await persist_message(
|
||
|
|
db,
|
||
|
|
user_id=user.id,
|
||
|
|
account_id=account.id,
|
||
|
|
mailbox_id=inbox.id,
|
||
|
|
mailbox_special_use="inbox",
|
||
|
|
remote=RemoteMessage(
|
||
|
|
uid=1,
|
||
|
|
raw=_raw("Your receipt"),
|
||
|
|
flags=frozenset({"\\Seen"}),
|
||
|
|
internal_date=datetime.now(UTC),
|
||
|
|
),
|
||
|
|
)
|
||
|
|
assert result is not None
|
||
|
|
tid = result.message.thread_id
|
||
|
|
assert tid is not None
|
||
|
|
# A read message whose thread still says "unread" (the bug), and a
|
||
|
|
# thread left behind with no messages at all.
|
||
|
|
await db.execute(update(Thread).where(Thread.id == tid).values(unread_count=1))
|
||
|
|
empty = Thread(
|
||
|
|
user_id=user.id,
|
||
|
|
subject_norm="gone",
|
||
|
|
message_count=1,
|
||
|
|
unread_count=1,
|
||
|
|
last_message_at=datetime.now(UTC),
|
||
|
|
)
|
||
|
|
db.add(empty)
|
||
|
|
# An emptied thread a reply draft still points at: kept, counts zeroed.
|
||
|
|
replied = Thread(
|
||
|
|
user_id=user.id,
|
||
|
|
subject_norm="replying",
|
||
|
|
message_count=1,
|
||
|
|
unread_count=0,
|
||
|
|
last_message_at=datetime.now(UTC),
|
||
|
|
)
|
||
|
|
db.add(replied)
|
||
|
|
await db.flush()
|
||
|
|
db.add(Draft(user_id=user.id, account_id=account.id, thread_id=replied.id, payload={}))
|
||
|
|
await db.flush()
|
||
|
|
empty_id = empty.id
|
||
|
|
replied_id = replied.id
|
||
|
|
try:
|
||
|
|
async with session_scope() as db:
|
||
|
|
assert await repair_threads(db, user.id) == 3
|
||
|
|
async with session_scope() as db:
|
||
|
|
thread = await db.get(Thread, tid)
|
||
|
|
assert thread is not None and thread.unread_count == 0 and thread.message_count == 1
|
||
|
|
assert await db.get(Thread, empty_id) is None
|
||
|
|
kept = await db.get(Thread, replied_id)
|
||
|
|
assert kept is not None and kept.message_count == 0
|
||
|
|
assert await repair_threads(db, user.id) == 0 # nothing left to repair
|
||
|
|
finally:
|
||
|
|
async with session_scope() as db:
|
||
|
|
await db.execute(delete(User).where(User.id == user.id)) # cascades
|
||
|
|
assert await db.scalar(select(User.id).where(User.id == user.id)) is None
|