import json import os import sqlite3 import sys from datetime import datetime, timedelta from pathlib import Path from unittest.mock import MagicMock, patch import pytest SCRIPTS = Path(__file__).parent.parent / "scripts" sys.path.insert(0, str(SCRIPTS)) from db import get_db, init_db import remind_send def _run_send(db_path, now=None): """Run remind_send main with a temporary DB path and optional mocked now.""" original_db_path = remind_send.DB_PATH try: remind_send.DB_PATH = db_path if now is not None: remind_send._now = lambda: now remind_send.main() finally: remind_send.DB_PATH = original_db_path remind_send._now = lambda: datetime.now(remind_send.TZ).replace(tzinfo=None) def test_due_at_delivers_once(tmp_path, capsys): db_path = tmp_path / "test.sqlite" init_db(db_path) conn = get_db(db_path) try: conn.execute( "INSERT INTO reminders (text, enabled, timezone, created_at, updated_at) VALUES (?, 1, 'Europe/Prague', 'now', 'now')", ("at reminder",), ) rid = conn.execute("SELECT last_insert_rowid()").fetchone()[0] fire_time = "2026-06-10T10:00:00" conn.execute( "INSERT INTO schedule_at (reminder_id, at_datetime) VALUES (?, ?)", (rid, fire_time), ) finally: conn.close() now = datetime(2026, 6, 10, 10, 0, 0) with patch.object(remind_send, "_send_telegram", return_value=None) as mock_send: _run_send(db_path, now) mock_send.assert_called_once_with("⏰ Reminder: at reminder") # second run — dedup with patch.object(remind_send, "_send_telegram", return_value=None) as mock_send: _run_send(db_path, now) mock_send.assert_not_called() def test_due_cron_delivers_once(tmp_path, capsys): db_path = tmp_path / "test.sqlite" init_db(db_path) conn = get_db(db_path) try: conn.execute( "INSERT INTO reminders (text, enabled, timezone, created_at, updated_at) VALUES (?, 1, 'Europe/Prague', 'now', 'now')", ("cron reminder",), ) rid = conn.execute("SELECT last_insert_rowid()").fetchone()[0] conn.execute( "INSERT INTO schedule_cron (reminder_id, cron_expr) VALUES (?, ?)", (rid, "0 10 * * *"), ) finally: conn.close() now = datetime(2026, 6, 10, 10, 0, 0) with patch.object(remind_send, "_send_telegram", return_value=None) as mock_send: _run_send(db_path, now) mock_send.assert_called_once_with("⏰ Reminder: cron reminder") # second run — dedup with patch.object(remind_send, "_send_telegram", return_value=None) as mock_send: _run_send(db_path, now) mock_send.assert_not_called() def test_due_random_delivers_once(tmp_path, capsys): db_path = tmp_path / "test.sqlite" init_db(db_path) conn = get_db(db_path) try: conn.execute( "INSERT INTO reminders (text, enabled, timezone, created_at, updated_at) VALUES (?, 1, 'Europe/Prague', 'now', 'now')", ("random reminder",), ) rid = conn.execute("SELECT last_insert_rowid()").fetchone()[0] conn.execute( "INSERT INTO schedule_random (reminder_id, times_per_day, window_start, window_end) VALUES (?, ?, ?, ?)", (rid, 2, 540, 1260), ) finally: conn.close() # compute expected fire times for the date from random_times import compute_fire_times fires = compute_fire_times(datetime(2026, 6, 10).date(), "random reminder", {"times_per_day": 2, "window": "09:00-21:00"}) assert len(fires) == 2 for ft in fires: with patch.object(remind_send, "_send_telegram", return_value=None) as mock_send: _run_send(db_path, ft) mock_send.assert_called_once_with("⏰ Reminder: random reminder") # all deduped now for ft in fires: with patch.object(remind_send, "_send_telegram", return_value=None) as mock_send: _run_send(db_path, ft) mock_send.assert_not_called() def test_disabled_not_sent(tmp_path, capsys): db_path = tmp_path / "test.sqlite" init_db(db_path) conn = get_db(db_path) try: conn.execute( "INSERT INTO reminders (text, enabled, timezone, created_at, updated_at) VALUES (?, 0, 'Europe/Prague', 'now', 'now')", ("disabled reminder",), ) rid = conn.execute("SELECT last_insert_rowid()").fetchone()[0] conn.execute( "INSERT INTO schedule_at (reminder_id, at_datetime) VALUES (?, ?)", (rid, "2026-06-10T10:00:00"), ) finally: conn.close() now = datetime(2026, 6, 10, 10, 0, 0) with patch.object(remind_send, "_send_telegram", return_value=None) as mock_send: _run_send(db_path, now) mock_send.assert_not_called() def test_deleted_not_sent(tmp_path, capsys): db_path = tmp_path / "test.sqlite" init_db(db_path) conn = get_db(db_path) try: conn.execute( "INSERT INTO reminders (text, enabled, timezone, created_at, updated_at, deleted_at) VALUES (?, 1, 'Europe/Prague', 'now', 'now', 'now')", ("deleted reminder",), ) rid = conn.execute("SELECT last_insert_rowid()").fetchone()[0] conn.execute( "INSERT INTO schedule_at (reminder_id, at_datetime) VALUES (?, ?)", (rid, "2026-06-10T10:00:00"), ) finally: conn.close() now = datetime(2026, 6, 10, 10, 0, 0) with patch.object(remind_send, "_send_telegram", return_value=None) as mock_send: _run_send(db_path, now) mock_send.assert_not_called() def test_delivery_failure_logged(tmp_path, capsys): db_path = tmp_path / "test.sqlite" init_db(db_path) conn = get_db(db_path) try: conn.execute( "INSERT INTO reminders (text, enabled, timezone, created_at, updated_at) VALUES (?, 1, 'Europe/Prague', 'now', 'now')", ("fail reminder",), ) rid = conn.execute("SELECT last_insert_rowid()").fetchone()[0] conn.execute( "INSERT INTO schedule_at (reminder_id, at_datetime) VALUES (?, ?)", (rid, "2026-06-10T10:00:00"), ) finally: conn.close() now = datetime(2026, 6, 10, 10, 0, 0) with patch.object(remind_send, "_send_telegram", side_effect=RuntimeError("network down")) as mock_send: _run_send(db_path, now) mock_send.assert_called_once() conn = get_db(db_path) try: row = conn.execute( "SELECT status, error_message FROM reminder_fires WHERE reminder_id = ?", (rid,) ).fetchone() assert row["status"] == "failed" assert "network down" in row["error_message"] finally: conn.close() def test_schedule_type_correct_despite_id_collision(tmp_path): """A cron fire must record schedule_type='cron' even when schedule_cron.id collides with a schedule_at.id (each schedule table has its own AUTOINCREMENT sequence).""" db_path = tmp_path / "test.sqlite" init_db(db_path) conn = get_db(db_path) try: conn.execute( "INSERT INTO reminders (text, enabled, timezone, created_at, updated_at) VALUES ('at one', 1, 'Europe/Prague', 'now', 'now')" ) rid_at = conn.execute("SELECT last_insert_rowid()").fetchone()[0] conn.execute( "INSERT INTO schedule_at (reminder_id, at_datetime) VALUES (?, ?)", (rid_at, "2030-01-01T00:00:00") ) conn.execute( "INSERT INTO reminders (text, enabled, timezone, created_at, updated_at) VALUES ('cron one', 1, 'Europe/Prague', 'now', 'now')" ) rid_cron = conn.execute("SELECT last_insert_rowid()").fetchone()[0] conn.execute( "INSERT INTO schedule_cron (reminder_id, cron_expr) VALUES (?, ?)", (rid_cron, "0 10 * * *") ) # schedule_at.id and schedule_cron.id both equal 1 here — the collision the fix guards against. assert conn.execute("SELECT id FROM schedule_at").fetchone()["id"] == 1 assert conn.execute("SELECT id FROM schedule_cron").fetchone()["id"] == 1 finally: conn.close() now = datetime(2026, 6, 10, 10, 0, 0) with patch.object(remind_send, "_send_telegram", return_value=None): _run_send(db_path, now) conn = get_db(db_path) try: row = conn.execute( "SELECT schedule_type, status FROM reminder_fires WHERE reminder_id = ?", (rid_cron,) ).fetchone() assert row["schedule_type"] == "cron" assert row["status"] == "delivered" finally: conn.close()