2026-07-17 23:20:34 +02:00
|
|
|
"""Flag / move actions (SPEC §6.3, §9.1): DB-first + enqueue + revert."""
|
|
|
|
|
|
|
|
|
|
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
|
2026-10-05 22:08:42 +02:00
|
|
|
import pytest
|
2026-07-17 23:20:34 +02:00
|
|
|
import pytest_asyncio
|
2026-10-05 22:08:42 +02:00
|
|
|
from arq import Retry
|
2026-07-17 23:20:34 +02:00
|
|
|
from httpx import ASGITransport
|
|
|
|
|
from sqlalchemy import delete, func, select
|
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
|
|
|
|
from sqlalchemy.pool import NullPool
|
|
|
|
|
|
2026-07-18 19:53:31 +02:00
|
|
|
from app import queue as app_queue
|
2026-07-17 23:20:34 +02:00
|
|
|
from app.config import get_settings
|
|
|
|
|
from app.db import session_scope
|
|
|
|
|
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, Thread, User
|
2026-10-05 22:08:42 +02:00
|
|
|
from app.providers.base import ProviderConnectError, RemoteMessage
|
2026-07-17 23:20:34 +02:00
|
|
|
from app.sync import actions as sync_actions
|
|
|
|
|
|
|
|
|
|
CSRF = "csrf"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
class Ctx:
|
|
|
|
|
client: httpx.AsyncClient
|
|
|
|
|
session: AsyncSession
|
|
|
|
|
user_id: uuid.UUID
|
|
|
|
|
inbox_id: uuid.UUID
|
|
|
|
|
archive_id: uuid.UUID
|
|
|
|
|
message_ids: list[uuid.UUID] = field(default_factory=list)
|
|
|
|
|
thread_id: uuid.UUID | None = None
|
|
|
|
|
enqueued: list[tuple] = field(default_factory=list)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _hdr() -> dict[str, str]:
|
|
|
|
|
return {"X-CSRF": CSRF}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _raw(subject: str, mid: str, refs: str | None = None) -> bytes:
|
|
|
|
|
m = EmailMessage()
|
|
|
|
|
m["Subject"] = subject
|
|
|
|
|
m["From"] = "a@x.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.set_content("body")
|
|
|
|
|
return m.as_bytes()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
|
|
|
async def ctx(monkeypatch) -> AsyncIterator[Ctx]: # 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"act{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")
|
|
|
|
|
archive = Mailbox(account_id=account.id, user_id=user.id, path="Archive", special_use="archive")
|
|
|
|
|
session.add_all([inbox, archive])
|
|
|
|
|
await session.commit()
|
|
|
|
|
|
|
|
|
|
c = Ctx(
|
|
|
|
|
client=None,
|
|
|
|
|
session=session,
|
|
|
|
|
user_id=user.id,
|
|
|
|
|
inbox_id=inbox.id, # type: ignore[arg-type]
|
|
|
|
|
archive_id=archive.id,
|
|
|
|
|
)
|
|
|
|
|
for i, (subj, mid, refs) in enumerate(
|
|
|
|
|
[
|
|
|
|
|
("Topic", "<a@x.com>", None),
|
|
|
|
|
("Re: Topic", "<b@x.com>", "<a@x.com>"),
|
|
|
|
|
]
|
|
|
|
|
):
|
|
|
|
|
remote = RemoteMessage(
|
|
|
|
|
uid=i + 1, raw=_raw(subj, mid, refs), flags=frozenset(), internal_date=datetime.now(UTC)
|
|
|
|
|
)
|
|
|
|
|
result = await persist_message(
|
|
|
|
|
session,
|
|
|
|
|
user_id=user.id,
|
|
|
|
|
account_id=account.id,
|
|
|
|
|
mailbox_id=inbox.id,
|
|
|
|
|
mailbox_special_use="inbox",
|
|
|
|
|
remote=remote,
|
|
|
|
|
)
|
|
|
|
|
await session.commit()
|
|
|
|
|
assert result is not None
|
|
|
|
|
c.message_ids.append(result.message.id)
|
|
|
|
|
c.thread_id = result.message.thread_id
|
|
|
|
|
|
|
|
|
|
# capture enqueues instead of hitting a real arq pool
|
|
|
|
|
class FakePool:
|
2026-07-18 19:53:31 +02:00
|
|
|
async def enqueue_job(self, *args: object, **kwargs: object) -> None:
|
2026-07-17 23:20:34 +02:00
|
|
|
c.enqueued.append(args)
|
|
|
|
|
|
|
|
|
|
async def fake_pool() -> FakePool:
|
|
|
|
|
return FakePool()
|
|
|
|
|
|
2026-07-18 19:53:31 +02:00
|
|
|
monkeypatch.setattr(app_queue, "get_arq_pool", fake_pool)
|
2026-07-17 23:20:34 +02:00
|
|
|
|
|
|
|
|
token = await create_session(user.id, "pytest")
|
|
|
|
|
c.client = httpx.AsyncClient(
|
|
|
|
|
transport=ASGITransport(app=create_app()),
|
|
|
|
|
base_url="http://testserver",
|
|
|
|
|
cookies={"session": token, "csrf": CSRF},
|
|
|
|
|
)
|
|
|
|
|
try:
|
|
|
|
|
yield c
|
|
|
|
|
finally:
|
|
|
|
|
await c.client.aclose()
|
|
|
|
|
await session.execute(delete(User).where(User.id == user.id))
|
|
|
|
|
await session.commit()
|
|
|
|
|
await session.close()
|
|
|
|
|
await engine.dispose()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_mark_message_read_enqueues_push(ctx: Ctx) -> None:
|
|
|
|
|
mid = ctx.message_ids[0]
|
|
|
|
|
resp = await ctx.client.post(
|
|
|
|
|
f"/api/messages/{mid}/flags", json={"add": ["\\Seen"]}, headers=_hdr()
|
|
|
|
|
)
|
|
|
|
|
assert resp.status_code == 200
|
|
|
|
|
assert "\\Seen" in resp.json()["flags"]
|
|
|
|
|
|
|
|
|
|
await ctx.session.refresh(await ctx.session.get(Message, mid))
|
|
|
|
|
msg = await ctx.session.get(Message, mid)
|
|
|
|
|
assert msg is not None and "\\Seen" in msg.flags
|
|
|
|
|
assert ctx.enqueued == [("push_flags_job", str(mid), ["\\Seen"], [])]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_noop_flag_change_does_not_enqueue(ctx: Ctx) -> None:
|
|
|
|
|
mid = ctx.message_ids[0]
|
|
|
|
|
await ctx.client.post(f"/api/messages/{mid}/flags", json={"add": ["\\Seen"]}, headers=_hdr())
|
|
|
|
|
ctx.enqueued.clear()
|
|
|
|
|
# already \Seen -> no-op
|
|
|
|
|
resp = await ctx.client.post(
|
|
|
|
|
f"/api/messages/{mid}/flags", json={"add": ["\\Seen"]}, headers=_hdr()
|
|
|
|
|
)
|
|
|
|
|
assert resp.status_code == 200
|
|
|
|
|
assert ctx.enqueued == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_thread_flags_mark_all_read(ctx: Ctx) -> None:
|
|
|
|
|
resp = await ctx.client.post(
|
|
|
|
|
f"/api/threads/{ctx.thread_id}/flags", json={"add": ["\\Seen"]}, headers=_hdr()
|
|
|
|
|
)
|
|
|
|
|
assert resp.status_code == 200
|
|
|
|
|
thread = await ctx.session.get(Thread, ctx.thread_id)
|
|
|
|
|
await ctx.session.refresh(thread)
|
|
|
|
|
assert thread is not None and thread.unread_count == 0
|
|
|
|
|
assert len(ctx.enqueued) == 2 # both messages pushed
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_thread_flag_sets_pinned(ctx: Ctx) -> None:
|
|
|
|
|
resp = await ctx.client.post(
|
|
|
|
|
f"/api/threads/{ctx.thread_id}/flags", json={"add": ["\\Flagged"]}, headers=_hdr()
|
|
|
|
|
)
|
|
|
|
|
assert resp.status_code == 200
|
|
|
|
|
thread = await ctx.session.get(Thread, ctx.thread_id)
|
|
|
|
|
await ctx.session.refresh(thread)
|
|
|
|
|
assert thread is not None and thread.pinned is True
|
|
|
|
|
|
|
|
|
|
# Unpinning clears it again.
|
|
|
|
|
await ctx.client.post(
|
|
|
|
|
f"/api/threads/{ctx.thread_id}/flags", json={"remove": ["\\Flagged"]}, headers=_hdr()
|
|
|
|
|
)
|
|
|
|
|
await ctx.session.refresh(thread)
|
|
|
|
|
assert thread.pinned is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_move_message(ctx: Ctx) -> None:
|
|
|
|
|
mid = ctx.message_ids[0]
|
|
|
|
|
resp = await ctx.client.post(
|
|
|
|
|
f"/api/messages/{mid}/move", json={"mailbox_id": str(ctx.archive_id)}, headers=_hdr()
|
|
|
|
|
)
|
|
|
|
|
assert resp.status_code == 200
|
|
|
|
|
msg = await ctx.session.get(Message, mid)
|
|
|
|
|
await ctx.session.refresh(msg)
|
|
|
|
|
assert msg is not None and msg.mailbox_id == ctx.archive_id
|
|
|
|
|
assert ctx.enqueued[0][0] == "move_job"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_archive_thread_enqueues_move(ctx: Ctx) -> None:
|
|
|
|
|
resp = await ctx.client.post(f"/api/threads/{ctx.thread_id}/archive", headers=_hdr())
|
|
|
|
|
assert resp.status_code == 200
|
|
|
|
|
assert resp.json() == {"status": "queued"}
|
|
|
|
|
assert ctx.enqueued == [
|
|
|
|
|
("move_thread_to_folder_job", str(ctx.thread_id), "archive"),
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_trash_thread_enqueues_move(ctx: Ctx) -> None:
|
|
|
|
|
resp = await ctx.client.post(f"/api/threads/{ctx.thread_id}/trash", headers=_hdr())
|
|
|
|
|
assert resp.status_code == 200
|
|
|
|
|
assert ctx.enqueued == [
|
|
|
|
|
("move_thread_to_folder_job", str(ctx.thread_id), "trash"),
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_archive_unknown_thread_404(ctx: Ctx) -> None:
|
|
|
|
|
resp = await ctx.client.post(f"/api/threads/{uuid.uuid4()}/archive", headers=_hdr())
|
|
|
|
|
assert resp.status_code == 404
|
|
|
|
|
assert ctx.enqueued == []
|
|
|
|
|
|
|
|
|
|
|
2026-07-20 23:12:25 +02:00
|
|
|
async def test_restore_thread_enqueues_restore_job(ctx: Ctx) -> None:
|
2026-07-17 23:20:34 +02:00
|
|
|
resp = await ctx.client.post(f"/api/threads/{ctx.thread_id}/restore", headers=_hdr())
|
|
|
|
|
assert resp.status_code == 200
|
|
|
|
|
assert ctx.enqueued == [
|
2026-07-20 23:12:25 +02:00
|
|
|
("restore_thread_job", str(ctx.thread_id)),
|
2026-07-17 23:20:34 +02:00
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
2026-07-20 23:12:25 +02:00
|
|
|
async def test_restore_unknown_thread_404(ctx: Ctx) -> None:
|
|
|
|
|
resp = await ctx.client.post(f"/api/threads/{uuid.uuid4()}/restore", headers=_hdr())
|
|
|
|
|
assert resp.status_code == 404
|
|
|
|
|
assert ctx.enqueued == []
|
|
|
|
|
|
|
|
|
|
|
2026-07-17 23:20:34 +02:00
|
|
|
async def test_delete_thread_enqueues_permanent_delete(ctx: Ctx) -> None:
|
|
|
|
|
resp = await ctx.client.post(f"/api/threads/{ctx.thread_id}/delete", headers=_hdr())
|
|
|
|
|
assert resp.status_code == 200
|
|
|
|
|
assert ctx.enqueued == [("delete_thread_job", str(ctx.thread_id))]
|
|
|
|
|
|
|
|
|
|
|
2026-07-22 07:40:29 +02:00
|
|
|
async def test_empty_folder_enqueues_job(ctx: Ctx) -> None:
|
|
|
|
|
resp = await ctx.client.post("/api/folders/trash/empty", headers=_hdr())
|
|
|
|
|
assert resp.status_code == 200
|
|
|
|
|
assert ctx.enqueued == [("empty_folder_job", str(ctx.user_id), "trash")]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_empty_folder_rejects_non_removable_folder(ctx: Ctx) -> None:
|
|
|
|
|
resp = await ctx.client.post("/api/folders/inbox/empty", headers=_hdr())
|
|
|
|
|
assert resp.status_code == 400
|
|
|
|
|
assert ctx.enqueued == []
|
|
|
|
|
|
|
|
|
|
|
2026-07-17 23:20:34 +02:00
|
|
|
async def test_ignores_unknown_flags(ctx: Ctx) -> None:
|
|
|
|
|
mid = ctx.message_ids[0]
|
|
|
|
|
resp = await ctx.client.post(
|
|
|
|
|
f"/api/messages/{mid}/flags", json={"add": ["\\Deleted", "bogus"]}, headers=_hdr()
|
|
|
|
|
)
|
|
|
|
|
assert resp.status_code == 200
|
|
|
|
|
msg = await ctx.session.get(Message, mid)
|
|
|
|
|
await ctx.session.refresh(msg)
|
|
|
|
|
assert msg is not None and "\\Deleted" not in msg.flags # not user-settable here
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_delete_thread_job_expunges_and_removes_rows(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
|
|
|
|
tid = ctx.thread_id
|
|
|
|
|
assert tid is not None
|
|
|
|
|
deleted: list[tuple[str, list[int]]] = []
|
|
|
|
|
|
|
|
|
|
class FakeProvider:
|
|
|
|
|
async def delete_messages(self, mailbox: str, uids: list[int]) -> None:
|
|
|
|
|
deleted.append((mailbox, uids))
|
|
|
|
|
|
|
|
|
|
async def close(self) -> None:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(sync_actions, "build_provider", lambda _account: FakeProvider())
|
|
|
|
|
|
|
|
|
|
await sync_actions.delete_thread_job({}, str(tid))
|
|
|
|
|
|
|
|
|
|
# server expunge happened for the thread's INBOX uids
|
|
|
|
|
assert deleted and deleted[0][0] == "INBOX"
|
|
|
|
|
# DB rows for the thread's messages are gone (verify in a fresh session; the
|
|
|
|
|
# job deleted them in its own session, so drop ctx's stale identity map).
|
|
|
|
|
ctx.session.expunge_all()
|
|
|
|
|
async with session_scope() as verify:
|
|
|
|
|
remaining = await verify.scalar(
|
|
|
|
|
select(func.count()).select_from(Message).where(Message.thread_id == tid)
|
|
|
|
|
)
|
|
|
|
|
assert remaining == 0
|
|
|
|
|
assert await verify.scalar(select(Thread).where(Thread.id == tid)) is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_push_flags_job_reverts_on_failure(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
|
|
|
|
mid = ctx.message_ids[0]
|
|
|
|
|
# mark read in DB first (as the endpoint would)
|
|
|
|
|
msg = await ctx.session.get(Message, mid)
|
|
|
|
|
assert msg is not None
|
|
|
|
|
msg.flags = ["\\Seen"]
|
|
|
|
|
await ctx.session.commit()
|
|
|
|
|
|
|
|
|
|
class FailingProvider:
|
|
|
|
|
async def push_flags(self, *a: object, **k: object) -> None:
|
|
|
|
|
raise RuntimeError("imap down")
|
|
|
|
|
|
|
|
|
|
async def close(self) -> None:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(sync_actions, "build_provider", lambda _account: FailingProvider())
|
|
|
|
|
|
|
|
|
|
# final try -> should revert (remove the \Seen we added)
|
|
|
|
|
await sync_actions.push_flags_job({"job_try": 5}, str(mid), ["\\Seen"], [])
|
|
|
|
|
reverted = await ctx.session.get(Message, mid)
|
|
|
|
|
await ctx.session.refresh(reverted)
|
|
|
|
|
assert reverted is not None and "\\Seen" not in reverted.flags
|
2026-10-05 22:08:42 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- the mail server unreachable: real retries (arq Retry), ~24 hours -------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _Unreachable:
|
|
|
|
|
"""A provider whose server cannot be reached."""
|
|
|
|
|
|
|
|
|
|
def __init__(self) -> None:
|
|
|
|
|
self.calls = 0
|
|
|
|
|
|
|
|
|
|
async def push_flags(self, *a: object, **k: object) -> None:
|
|
|
|
|
self.calls += 1
|
|
|
|
|
raise ProviderConnectError("cannot connect to imap.example.com")
|
|
|
|
|
|
|
|
|
|
async def move(self, *a: object, **k: object) -> int:
|
|
|
|
|
self.calls += 1
|
|
|
|
|
raise ProviderConnectError("cannot connect to imap.example.com")
|
|
|
|
|
|
|
|
|
|
async def delete_messages(self, *a: object, **k: object) -> None:
|
|
|
|
|
self.calls += 1
|
|
|
|
|
raise ProviderConnectError("cannot connect to imap.example.com")
|
|
|
|
|
|
|
|
|
|
async def close(self) -> None:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _mark_read(ctx: Ctx) -> uuid.UUID:
|
|
|
|
|
mid = ctx.message_ids[0]
|
|
|
|
|
msg = await ctx.session.get(Message, mid)
|
|
|
|
|
assert msg is not None
|
|
|
|
|
msg.flags = ["\\Seen"]
|
|
|
|
|
await ctx.session.commit()
|
|
|
|
|
return mid
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_flag_push_retries_while_unreachable(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
|
|
|
|
mid = await _mark_read(ctx)
|
|
|
|
|
monkeypatch.setattr(sync_actions, "build_provider", lambda _a: _Unreachable())
|
|
|
|
|
|
|
|
|
|
with pytest.raises(Retry) as caught:
|
|
|
|
|
await sync_actions.push_flags_job({"job_try": 1}, str(mid), ["\\Seen"], [])
|
|
|
|
|
assert caught.value.defer_score == 30_000 # first gap: 30 s
|
|
|
|
|
msg = await ctx.session.get(Message, mid)
|
|
|
|
|
await ctx.session.refresh(msg)
|
|
|
|
|
assert msg is not None and "\\Seen" in msg.flags # kept while retrying
|
|
|
|
|
|
|
|
|
|
# The last try still cannot reach it: the change is rolled back.
|
|
|
|
|
await sync_actions.push_flags_job(
|
|
|
|
|
{"job_try": sync_actions.ACTION_MAX_TRIES}, str(mid), ["\\Seen"], []
|
|
|
|
|
)
|
|
|
|
|
await ctx.session.refresh(msg)
|
|
|
|
|
assert "\\Seen" not in msg.flags
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_retry_gaps_cover_about_a_day() -> None:
|
|
|
|
|
total = sum(sync_actions._RETRY_DEFERS_S)
|
|
|
|
|
assert 23 * 3600 < total <= 24 * 3600
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_superseded_flag_change_is_not_pushed(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
|
|
|
|
# "Read" was queued, then the message was marked unread again: the stale
|
|
|
|
|
# "read" must not reach the server after the newer change.
|
|
|
|
|
mid = ctx.message_ids[0]
|
|
|
|
|
provider = _Unreachable()
|
|
|
|
|
monkeypatch.setattr(sync_actions, "build_provider", lambda _a: provider)
|
|
|
|
|
await sync_actions.push_flags_job({"job_try": 3}, str(mid), ["\\Seen"], [])
|
|
|
|
|
assert provider.calls == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_archive_retries_while_unreachable(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
|
|
|
|
provider = _Unreachable()
|
|
|
|
|
monkeypatch.setattr(sync_actions, "build_provider", lambda _a: provider)
|
|
|
|
|
with pytest.raises(Retry):
|
|
|
|
|
await sync_actions.move_thread_to_folder_job({"job_try": 1}, str(ctx.thread_id), "archive")
|
|
|
|
|
assert provider.calls == 1 # stopped at the first failure, not 20 s per message
|
|
|
|
|
ctx.session.expunge_all()
|
|
|
|
|
async with session_scope() as verify:
|
|
|
|
|
inbox = await verify.scalar(
|
|
|
|
|
select(Mailbox).where(Mailbox.user_id == ctx.user_id, Mailbox.special_use == "inbox")
|
|
|
|
|
)
|
|
|
|
|
assert inbox is not None
|
|
|
|
|
moved = await verify.scalar(
|
|
|
|
|
select(func.count())
|
|
|
|
|
.select_from(Message)
|
|
|
|
|
.where(Message.thread_id == ctx.thread_id, Message.mailbox_id != inbox.id)
|
|
|
|
|
)
|
|
|
|
|
assert moved == 0 # nothing recorded as moved
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_delete_keeps_rows_while_unreachable(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
|
|
|
|
monkeypatch.setattr(sync_actions, "build_provider", lambda _a: _Unreachable())
|
|
|
|
|
with pytest.raises(Retry):
|
|
|
|
|
await sync_actions.delete_thread_job({"job_try": 1}, str(ctx.thread_id))
|
|
|
|
|
ctx.session.expunge_all()
|
|
|
|
|
async with session_scope() as verify:
|
|
|
|
|
left = await verify.scalar(
|
|
|
|
|
select(func.count()).select_from(Message).where(Message.thread_id == ctx.thread_id)
|
|
|
|
|
)
|
|
|
|
|
assert left == len(ctx.message_ids) # kept: the server still has them
|
2026-10-14 22:08:06 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_restore_moves_only_what_was_removed(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
|
|
|
|
"""A thread restored from Spam brings its spam back; the user's own reply in
|
|
|
|
|
Sent (never removed, so no recorded origin) stays in Sent."""
|
|
|
|
|
sent = Mailbox(account_id=None, user_id=ctx.user_id, path="Sent", special_use="sent")
|
|
|
|
|
first = await ctx.session.get(Message, ctx.message_ids[0])
|
|
|
|
|
reply = await ctx.session.get(Message, ctx.message_ids[1])
|
|
|
|
|
assert first is not None and reply is not None
|
|
|
|
|
sent.account_id = first.account_id
|
|
|
|
|
ctx.session.add(sent)
|
|
|
|
|
await ctx.session.flush()
|
|
|
|
|
first.mailbox_id = ctx.archive_id # removed, origin unknown (as when synced in)
|
|
|
|
|
reply.mailbox_id = sent.id
|
|
|
|
|
await ctx.session.commit()
|
|
|
|
|
|
|
|
|
|
moves: list[tuple[int, str, str]] = []
|
|
|
|
|
|
|
|
|
|
class Recorder:
|
|
|
|
|
async def move(self, uid: int, src: str, dst: str) -> int:
|
|
|
|
|
moves.append((uid, src, dst))
|
|
|
|
|
return 100 + uid
|
|
|
|
|
|
|
|
|
|
async def close(self) -> None:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(sync_actions, "build_provider", lambda _a: Recorder())
|
|
|
|
|
await sync_actions.restore_thread_job({"job_try": 1}, str(ctx.thread_id))
|
|
|
|
|
assert moves == [(first.uid, "Archive", "INBOX")]
|
|
|
|
|
ctx.session.expunge_all()
|
|
|
|
|
async with session_scope() as verify:
|
|
|
|
|
kept = await verify.get(Message, ctx.message_ids[1])
|
|
|
|
|
assert kept is not None and kept.mailbox_id == sent.id
|