Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 16 additions & 8 deletions src/ian/services/reminder_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,17 +89,21 @@ def run_once(target_date: str | None = None, dry: bool = False):
message = format_reminder_message(events)
eprint(f"\n[Reminder] Message:\n{message}\n")

if dry:
eprint("[Reminder] DRY RUN — no messages sent.")
try:
members = load_members()
bound = get_valid_bound_members(members)
except Exception as e:
eprint(f"[Reminder] Failed to load member data: {e}")
send_log(f"```\n[REMINDER] FAILED to load member data: {e}\n```")
return

if dry:
eprint("[Reminder] DRY RUN — no messages sent.")
eprint(f"[Reminder] Would notify {len(bound)} member(s):")
for m in bound:
eprint(f" - {m['name']} (Discord)")
return

members = load_members()
bound = get_valid_bound_members(members)
eprint(f"[Reminder] Notifying {len(bound)} member(s)...")

discord_ok, discord_fail = 0, 0
Expand All @@ -115,11 +119,15 @@ def run_once(target_date: str | None = None, dry: bool = False):

if m["discord_id"]:
eprint(f" Sending Discord DM to {name}...")
if send_discord_dm(m["discord_id"], personal_message):
discord_ok += 1
eprint(f" [Discord] {name} OK")
else:
try:
if send_discord_dm(m["discord_id"], personal_message):
discord_ok += 1
eprint(f" [Discord] {name} OK")
else:
discord_fail += 1
except Exception as e:
discord_fail += 1
eprint(f" [Discord] {name} failed: {e}")
time.sleep(0.5)

event_titles = ", ".join(ev["title"] for ev in events)
Expand Down
34 changes: 31 additions & 3 deletions tests/domain/test_reminders.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from datetime import datetime, timedelta, timezone

import pandas as pd
import pytest

from ian.domain.reminders import (
clean_value,
Expand Down Expand Up @@ -170,6 +171,33 @@ def test_format_reminder_message_omits_empty_optional_event_fields():
assert "講義:" not in message


def test_seconds_until_next_run_returns_next_future_target():
now = datetime(2026, 3, 7, 20, 0, tzinfo=timezone(timedelta(hours=8)))
assert seconds_until_next_run(now, hour=19, minute=0) == 23 * 60 * 60
def test_format_reminder_message_includes_slides_when_present():
message = format_reminder_message(
[_event(slides="https://slides.example/course")]
)

assert "講義: https://slides.example/course" in message


@pytest.mark.parametrize(
("hour", "minute", "second", "expected"),
[
pytest.param(18, 59, 30, 30, id="before-target"),
pytest.param(19, 0, 0, 24 * 60 * 60, id="exact-target"),
pytest.param(20, 0, 0, 23 * 60 * 60, id="after-target"),
],
)
def test_seconds_until_next_run_handles_target_boundaries(
hour, minute, second, expected
):
now = datetime(
2026,
3,
7,
hour,
minute,
second,
tzinfo=timezone(timedelta(hours=8)),
)

assert seconds_until_next_run(now, hour=19, minute=0) == expected
287 changes: 287 additions & 0 deletions tests/services/test_reminder_runner.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,287 @@
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# Copyright (c) 2026 NTU AI Club
#
# This file is part of Ian, an open-source AI agent framework developed
# and maintained by NTU AI Club.
#
# Ian is licensed under the GNU General Public License, either version 3
# of the License, or (at your option) any later version.
#
# Ian is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
# See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ian. If not, see <https://www.gnu.org/licenses/>.
#

from datetime import datetime, timezone, timedelta

import pandas as pd
import pytest

from ian.services import reminder_runner


TARGET_DATE = "2026/07/12"
EVENTS = [
{
"title": "Agent Evaluation",
"time": "19:00",
}
]
MESSAGE = "Hi! 明天 NTUAI 有 Agent Evaluation"


def _stub_event_flow(monkeypatch, bound):
monkeypatch.setattr(reminder_runner, "fetch_course_data", pd.DataFrame)
monkeypatch.setattr(reminder_runner, "find_events_on_date", lambda *_: EVENTS)
monkeypatch.setattr(reminder_runner, "format_reminder_message", lambda *_: MESSAGE)
monkeypatch.setattr(reminder_runner, "load_members", list)
monkeypatch.setattr(reminder_runner, "get_valid_bound_members", lambda *_: bound)


def test_run_once_logs_fetch_failure_without_loading_members(monkeypatch):
logs = []

def fail_fetch():
raise RuntimeError("sheet unavailable")

monkeypatch.setattr(reminder_runner, "fetch_course_data", fail_fetch)
monkeypatch.setattr(
reminder_runner,
"load_members",
lambda: (_ for _ in ()).throw(AssertionError("members should not be loaded")),
)
monkeypatch.setattr(reminder_runner, "send_discord_dm", lambda *_: False)
monkeypatch.setattr(reminder_runner, "send_log", logs.append)
monkeypatch.setattr(reminder_runner.time, "sleep", lambda *_: None)

reminder_runner.run_once(target_date=TARGET_DATE)

assert logs == [
"```\n[REMINDER] FAILED to fetch course data: sheet unavailable\n```"
]


def test_run_once_with_no_events_does_not_load_members_or_send_messages(monkeypatch):
monkeypatch.setattr(reminder_runner, "fetch_course_data", pd.DataFrame)
monkeypatch.setattr(
reminder_runner,
"load_members",
lambda: (_ for _ in ()).throw(AssertionError("members should not be loaded")),
)
monkeypatch.setattr(
reminder_runner,
"send_discord_dm",
lambda *_: (_ for _ in ()).throw(AssertionError("DM should not be sent")),
)
monkeypatch.setattr(
reminder_runner,
"send_log",
lambda *_: (_ for _ in ()).throw(AssertionError("log should not be sent")),
)
monkeypatch.setattr(reminder_runner.time, "sleep", lambda *_: None)

reminder_runner.run_once(target_date=TARGET_DATE)


def test_run_once_dry_run_lists_recipients_without_sending_messages(
monkeypatch, capsys
):
members = [{"name": "Alice"}, {"name": "Bob"}]
bound = [
{"name": "Alice", "email": "alice@example.test", "discord_id": "discord-1"},
{"name": "Bob", "email": "bob@example.test", "discord_id": "discord-2"},
]

monkeypatch.setattr(reminder_runner, "fetch_course_data", pd.DataFrame)
monkeypatch.setattr(reminder_runner, "find_events_on_date", lambda *_: EVENTS)
monkeypatch.setattr(reminder_runner, "format_reminder_message", lambda *_: MESSAGE)
monkeypatch.setattr(reminder_runner, "load_members", lambda: members)
monkeypatch.setattr(reminder_runner, "get_valid_bound_members", lambda value: bound)
monkeypatch.setattr(
reminder_runner,
"send_discord_dm",
lambda *_: (_ for _ in ()).throw(AssertionError("DM should not be sent")),
)
monkeypatch.setattr(
reminder_runner,
"send_log",
lambda *_: (_ for _ in ()).throw(AssertionError("log should not be sent")),
)
monkeypatch.setattr(reminder_runner.time, "sleep", lambda *_: None)

reminder_runner.run_once(target_date=TARGET_DATE, dry=True)

captured = capsys.readouterr()
assert "Would notify 2 member(s)" in captured.err
assert "Alice (Discord)" in captured.err
assert "Bob (Discord)" in captured.err


def test_run_once_sends_personalized_messages_and_reports_counts(monkeypatch):
bound = [
{
"name": "王 小明",
"email": "member+test@example.test",
"discord_id": "discord-success",
},
{
"name": "Failed Member",
"email": "failed@example.test",
"discord_id": "discord-failure",
},
]
dm_calls = []
log_calls = []
sleep_calls = []

def send_dm(discord_id, message):
dm_calls.append((discord_id, message))
return discord_id == "discord-success"

_stub_event_flow(monkeypatch, bound)
monkeypatch.setattr(reminder_runner, "send_discord_dm", send_dm)
monkeypatch.setattr(reminder_runner, "send_log", log_calls.append)
monkeypatch.setattr(reminder_runner.time, "sleep", sleep_calls.append)

reminder_runner.run_once(target_date=TARGET_DATE)

assert dm_calls == [
(
"discord-success",
f"{MESSAGE}\n\n簽到碼連結:https://watsonshih.github.io/QuickRecord/"
"user.html?name=%E7%8E%8B%20%E5%B0%8F%E6%98%8E&id=member%2Btest%40example.test",
),
(
"discord-failure",
f"{MESSAGE}\n\n簽到碼連結:https://watsonshih.github.io/QuickRecord/"
"user.html?name=Failed%20Member&id=failed%40example.test",
),
]
assert sleep_calls == [0.5, 0.5]
assert len(log_calls) == 1
assert f"Events on {TARGET_DATE}: Agent Evaluation" in log_calls[0]
assert "Discord: 1 sent, 1 failed" in log_calls[0]
assert "Total members notified: 1" in log_calls[0]


def test_run_once_logs_member_load_failure_without_sending_messages(monkeypatch):
logs = []

monkeypatch.setattr(reminder_runner, "fetch_course_data", pd.DataFrame)
monkeypatch.setattr(reminder_runner, "find_events_on_date", lambda *_: EVENTS)
monkeypatch.setattr(reminder_runner, "format_reminder_message", lambda *_: MESSAGE)
monkeypatch.setattr(
reminder_runner,
"load_members",
lambda: (_ for _ in ()).throw(ValueError("invalid member JSON")),
)
monkeypatch.setattr(
reminder_runner,
"send_discord_dm",
lambda *_: (_ for _ in ()).throw(AssertionError("DM should not be sent")),
)
monkeypatch.setattr(reminder_runner, "send_log", logs.append)

reminder_runner.run_once(target_date=TARGET_DATE)

assert logs == [
"```\n[REMINDER] FAILED to load member data: invalid member JSON\n```"
]


def test_run_once_continues_after_one_dm_raises(monkeypatch):
bound = [
{"name": "Alice", "email": "alice@example.test", "discord_id": "bad"},
{"name": "Bob", "email": "bob@example.test", "discord_id": "good"},
]
attempted = []
logs = []

def send_dm(discord_id, _message):
attempted.append(discord_id)
if discord_id == "bad":
raise TimeoutError("Discord timeout")
return True

_stub_event_flow(monkeypatch, bound)
monkeypatch.setattr(reminder_runner, "send_discord_dm", send_dm)
monkeypatch.setattr(reminder_runner, "send_log", logs.append)
monkeypatch.setattr(reminder_runner.time, "sleep", lambda *_: None)

reminder_runner.run_once(target_date=TARGET_DATE)

assert attempted == ["bad", "good"]
assert "Discord: 1 sent, 1 failed" in logs[0]


def test_run_once_with_no_recipients_reports_zero_counts(monkeypatch):
logs = []

_stub_event_flow(monkeypatch, [])
monkeypatch.setattr(
reminder_runner,
"send_discord_dm",
lambda *_: (_ for _ in ()).throw(AssertionError("DM should not be sent")),
)
monkeypatch.setattr(reminder_runner, "send_log", logs.append)

reminder_runner.run_once(target_date=TARGET_DATE)

assert "Discord: 0 sent, 0 failed" in logs[0]
assert "Total members notified: 0" in logs[0]


@pytest.mark.parametrize(
("name", "email"),
[
pytest.param("Alice", "", id="missing-email"),
pytest.param("", "alice@example.test", id="missing-name"),
pytest.param("", "", id="missing-name-and-email"),
],
)
def test_run_once_omits_checkin_link_without_complete_identity(
monkeypatch, name, email
):
bound = [{"name": name, "email": email, "discord_id": "discord-1"}]
messages = []

_stub_event_flow(monkeypatch, bound)
monkeypatch.setattr(
reminder_runner,
"send_discord_dm",
lambda _discord_id, message: messages.append(message) or True,
)
monkeypatch.setattr(reminder_runner, "send_log", lambda *_: None)
monkeypatch.setattr(reminder_runner.time, "sleep", lambda *_: None)

reminder_runner.run_once(target_date=TARGET_DATE)

assert messages == [MESSAGE]


def test_run_once_uses_taipei_tomorrow_when_target_date_is_omitted(monkeypatch):
checked_dates = []

class FixedDateTime(datetime):
@classmethod
def now(cls, tz=None):
return cls(2026, 12, 31, 23, 30, tzinfo=timezone(timedelta(hours=8)))

monkeypatch.setattr(reminder_runner, "datetime", FixedDateTime)
monkeypatch.setattr(reminder_runner, "fetch_course_data", pd.DataFrame)
monkeypatch.setattr(
reminder_runner,
"find_events_on_date",
lambda _df, target_date: checked_dates.append(target_date) or [],
)
monkeypatch.setattr(reminder_runner, "send_log", lambda *_: None)

reminder_runner.run_once()

assert checked_dates == ["2027/01/01"]