mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-09 08:22:19 +02:00
fix(reminders): retry failed note delivery through the configured channel (#6566)
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
"""Background note reminders retry failed primary delivery without losing receipts."""
|
||||
import datetime
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from tests.helpers.database import disposable_database
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def reminder_scan(tmp_path, monkeypatch):
|
||||
from core import database
|
||||
from routes import note_routes
|
||||
from src import builtin_actions, integrations, settings
|
||||
|
||||
instant = datetime.datetime(2026, 10, 7, 12, tzinfo=datetime.timezone.utc)
|
||||
|
||||
class Clock(datetime.datetime):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return instant.astimezone(tz) if tz else instant.replace(tzinfo=None)
|
||||
|
||||
calls, notices = [], []
|
||||
statuses = [503, 200]
|
||||
channel = {"reminder_channel": "webhook", "reminder_webhook_integration_id": "audit", "reminder_webhook_payload_template": '{"message":"{{message}}"}'}
|
||||
|
||||
def deliver(request):
|
||||
calls.append(request)
|
||||
return httpx.Response(statuses.pop(0))
|
||||
|
||||
real_client = httpx.AsyncClient
|
||||
monkeypatch.setattr(httpx, "AsyncClient", lambda **kw: real_client(transport=httpx.MockTransport(deliver), **kw))
|
||||
monkeypatch.setattr(settings, "load_settings", lambda: channel)
|
||||
monkeypatch.setattr(integrations, "load_integrations", lambda: [{"id": "audit", "preset": "ntfy", "base_url": "https://reminder.example.test", "enabled": True}])
|
||||
monkeypatch.setattr(note_routes, "_scheduler_ref", SimpleNamespace(add_notification=lambda **kw: notices.append(kw)))
|
||||
monkeypatch.setattr(note_routes, "DATA_DIR", str(tmp_path))
|
||||
monkeypatch.setattr(builtin_actions, "DATA_DIR", str(tmp_path))
|
||||
monkeypatch.setattr(datetime, "datetime", Clock)
|
||||
monkeypatch.setattr("src.url_safety.check_outbound_url", lambda *a, **kw: (True, ""))
|
||||
with disposable_database(tmp_path) as factory:
|
||||
monkeypatch.setattr(database, "SessionLocal", factory)
|
||||
with factory() as db:
|
||||
db.add(database.Note(id="note-1", owner="alice", title="Due note", due_date=instant.isoformat()))
|
||||
db.commit()
|
||||
yield SimpleNamespace(
|
||||
scan=lambda: builtin_actions.action_ping_notes("alice"),
|
||||
path=tmp_path / "note_pings_alice.json", calls=calls, notices=notices,
|
||||
statuses=statuses, channel=channel, instant=instant, factory=factory,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("channel", ["webhook", "ntfy"])
|
||||
async def test_failed_primary_is_retried_and_receipt_keeps_channel(reminder_scan, channel):
|
||||
from src.builtin_actions import TaskNoop
|
||||
scan = reminder_scan
|
||||
scan.channel["reminder_channel"] = channel
|
||||
with pytest.raises(TaskNoop):
|
||||
await scan.scan()
|
||||
assert len(scan.calls) == 1
|
||||
assert json.loads(scan.path.read_text())["note-1"]["channel"] == "browser"
|
||||
message, ok = await scan.scan()
|
||||
assert ok is True and "Pinged 1" in message
|
||||
assert len(scan.calls) == 2
|
||||
assert len(scan.notices) == 1
|
||||
assert json.loads(scan.path.read_text())["note-1"]["channel"] == channel
|
||||
with pytest.raises(TaskNoop):
|
||||
await scan.scan()
|
||||
assert len(scan.calls) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_legacy_browser_receipt_allows_external_retry(reminder_scan):
|
||||
scan = reminder_scan
|
||||
scan.path.write_text(json.dumps({"note-1": scan.instant.isoformat()}))
|
||||
scan.statuses[:] = [200]
|
||||
message, ok = await scan.scan()
|
||||
assert ok is True and "Pinged 1" in message
|
||||
assert len(scan.calls) == 1 and scan.notices == []
|
||||
assert json.loads(scan.path.read_text())["note-1"]["channel"] == "webhook"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_browser_delivery_is_deduped_and_typed(reminder_scan):
|
||||
from src.builtin_actions import TaskNoop
|
||||
scan = reminder_scan
|
||||
scan.channel["reminder_channel"] = "browser"
|
||||
_, ok = await scan.scan()
|
||||
assert ok is True
|
||||
assert json.loads(scan.path.read_text())["note-1"]["channel"] == "browser"
|
||||
with pytest.raises(TaskNoop):
|
||||
await scan.scan()
|
||||
assert len(scan.notices) == 1 and scan.calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_total_delivery_failure_does_not_checkpoint(reminder_scan, monkeypatch):
|
||||
from routes import note_routes
|
||||
from src.builtin_actions import TaskNoop
|
||||
scan = reminder_scan
|
||||
monkeypatch.setattr(note_routes, "_scheduler_ref", None)
|
||||
with pytest.raises(TaskNoop):
|
||||
await scan.scan()
|
||||
assert "note-1" not in json.loads(scan.path.read_text())
|
||||
_, ok = await scan.scan()
|
||||
assert ok is True and len(scan.calls) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pruning_retains_dispatcher_receipt_and_other_due_note(reminder_scan):
|
||||
from core import database
|
||||
scan = reminder_scan
|
||||
with scan.factory() as db:
|
||||
db.add(database.Note(id="later", owner="alice", title="Later", due_date=(scan.instant + datetime.timedelta(days=1)).isoformat()))
|
||||
db.commit()
|
||||
scan.path.write_text(json.dumps({"gone": "old", "later": {"at": scan.instant.isoformat(), "channel": "email"}}))
|
||||
scan.statuses[:] = [200]
|
||||
_, ok = await scan.scan()
|
||||
assert ok is True
|
||||
cache = json.loads(scan.path.read_text())
|
||||
assert set(cache) == {"note-1", "later"}
|
||||
assert cache["note-1"]["channel"] == "webhook"
|
||||
assert cache["later"]["channel"] == "email"
|
||||
Reference in New Issue
Block a user