diff --git a/src/ian/gateways/mcp_server.py b/src/ian/gateways/mcp_server.py
index 025d0e2..fd8d8be 100644
--- a/src/ian/gateways/mcp_server.py
+++ b/src/ian/gateways/mcp_server.py
@@ -45,7 +45,7 @@
update_personal_prompt as _update_personal_prompt,
update_subscribe as _update_subscribe,
)
-from ian.utils.console import eprint
+from ian.utils.logging import log_event
warnings.filterwarnings("ignore", message="pkg_resources is deprecated")
@@ -91,13 +91,51 @@ def initialize_dependencies() -> None:
rag.initialize_rag_system()
course_catalog.load_course_data_from_url(COURSE_DATA_URL)
except Exception as e:
- eprint(f"初始化錯誤: {e}")
+ log_event(
+ "operation_failed",
+ "mcp_server",
+ level="error",
+ status="error",
+ operation="initialize_data_sources",
+ error=e,
+ )
try:
init_member_db()
- eprint("社員資料庫已初始化")
+ log_event(
+ "operation_completed",
+ "mcp_server",
+ status="success",
+ operation="initialize_member_store",
+ )
except Exception as e:
- eprint(f"社員資料庫初始化失敗: {e}")
+ log_event(
+ "operation_failed",
+ "mcp_server",
+ level="error",
+ status="error",
+ operation="initialize_member_store",
+ error=e,
+ )
+
+
+def _log_mcp_tool_failure(
+ operation: str,
+ error: Exception,
+ *,
+ platform: str | None = None,
+ account_id: str | None = None,
+) -> None:
+ log_event(
+ "operation_failed",
+ "mcp_server",
+ level="error",
+ platform=platform,
+ status="error",
+ operation=operation,
+ account_id=account_id,
+ error=error,
+ )
# ---------------------------------------------------------------------------
@@ -135,14 +173,19 @@ async def search_course_chunks_by_semantics(
)
# 權限檢查 — 角色一律從綁定 DB 查,不採信 LLM 傳進來的字串
- has_permission, resolved_role = await asyncio.to_thread(
+ has_permission, _ = await asyncio.to_thread(
check_user_permission, platform, account_id, channel_id
)
- access_level = "完整權限" if has_permission else "受限權限"
- eprint(
- f"課程資料查詢 - 平台: {platform}, 帳號: {account_id}, "
- f"DB 角色: {resolved_role}, 頻道 ID: {channel_id}, "
- f"權限: {access_level}, 查詢: {query}"
+ log_event(
+ "tool_invoked",
+ "mcp_server",
+ platform=platform,
+ status="started",
+ operation="course_retriever",
+ account_id=account_id,
+ channel_id=channel_id,
+ has_permission=has_permission,
+ query_length=len(query),
)
# 檢查是否有查詢條件
@@ -274,7 +317,7 @@ async def notify_staff(message: str, user_name: str = "", platform: Optional[str
return "⚠️ 通知發送失敗,請稍後再試或透過其他管道聯繫幹部。"
except Exception as e:
- eprint(f"[notify_staff] 工具執行錯誤: {e}")
+ _log_mcp_tool_failure("notify_staff", e, platform=platform)
return f"⚠️ 通知發送時發生錯誤:{str(e)}"
@@ -323,7 +366,12 @@ async def generate_checkin_code(platform: str, account_id: str, name: str = "",
)
except Exception as e:
- eprint(f"[generate_checkin_code] 工具執行錯誤: {e}")
+ _log_mcp_tool_failure(
+ "generate_checkin_code",
+ e,
+ platform=platform,
+ account_id=account_id,
+ )
return f"⚠️ 產生簽到碼時發生錯誤:{str(e)}"
@@ -350,7 +398,12 @@ async def bind_email(email: str, platform: str, account_id: str) -> str:
result = _bind_email_to_platform(email, platform, account_id)
return result["message"]
except Exception as e:
- eprint(f"[bind_email] 工具執行錯誤: {e}")
+ _log_mcp_tool_failure(
+ "bind_email",
+ e,
+ platform=platform,
+ account_id=account_id,
+ )
return f"⚠️ 綁定時發生錯誤:{str(e)}"
@@ -374,7 +427,12 @@ async def update_subscribe(platform: str, account_id: str, subscribe: str) -> st
result = _update_subscribe(platform, account_id, subscribe)
return result["message"]
except Exception as e:
- eprint(f"[update_subscribe] 工具執行錯誤: {e}")
+ _log_mcp_tool_failure(
+ "update_subscribe",
+ e,
+ platform=platform,
+ account_id=account_id,
+ )
return f"⚠️ 更新訂閱設定時發生錯誤:{str(e)}"
@@ -398,7 +456,12 @@ async def update_personal_prompt(platform: str, account_id: str, personal_prompt
result = _update_personal_prompt(platform, account_id, personal_prompt)
return result["message"]
except Exception as e:
- eprint(f"[update_personal_prompt] 工具執行錯誤: {e}")
+ _log_mcp_tool_failure(
+ "update_personal_prompt",
+ e,
+ platform=platform,
+ account_id=account_id,
+ )
return f"⚠️ 更新個性備註時發生錯誤:{str(e)}"
@@ -498,7 +561,13 @@ async def notify_members(role: str, event_date: str = "", note: str = "", custom
# Mode A: custom message (no event needed)
if custom_message and custom_message.strip():
message = f"NTUAI 通知\n\n{custom_message.strip()}"
- eprint("[notify_members] Sending custom notification")
+ log_event(
+ "job_started",
+ "mcp_server",
+ status="started",
+ job="notify_members",
+ notification_type="custom",
+ )
members = await asyncio.to_thread(load_members)
result = await asyncio.to_thread(notifications.send_notification_to_members, message, members)
@@ -515,6 +584,16 @@ async def notify_members(role: str, event_date: str = "", note: str = "", custom
f"```\n[STAFF NOTIFY] Custom message\n"
f"Discord: {result['discord_ok']}/{result['discord_ok']+result['discord_fail']}\n```"
)
+ log_event(
+ "job_completed",
+ "mcp_server",
+ status="success" if result["discord_fail"] == 0 else "partial_failure",
+ job="notify_members",
+ notification_type="custom",
+ recipient_count=result["total_members"],
+ sent_count=result["discord_ok"],
+ failed_count=result["discord_fail"],
+ )
return summary
# Mode B: event notification
@@ -525,7 +604,14 @@ async def notify_members(role: str, event_date: str = "", note: str = "", custom
return f"找不到日期為 {event_date} 的活動,請確認日期格式為 YYYY/MM/DD。"
message = notifications.format_staff_notification(event, note=note.strip() if note else "")
- eprint(f"[notify_members] Sending notification for {event['title']} ({event_date})")
+ log_event(
+ "job_started",
+ "mcp_server",
+ status="started",
+ job="notify_members",
+ notification_type="event",
+ event_date=event_date,
+ )
members = await asyncio.to_thread(load_members)
result = await asyncio.to_thread(notifications.send_notification_to_members, message, members)
@@ -543,6 +629,17 @@ async def notify_members(role: str, event_date: str = "", note: str = "", custom
f"```\n[STAFF NOTIFY] {event['title']} ({event_date})\n"
f"Discord: {result['discord_ok']}/{result['discord_ok']+result['discord_fail']}\n```"
)
+ log_event(
+ "job_completed",
+ "mcp_server",
+ status="success" if result["discord_fail"] == 0 else "partial_failure",
+ job="notify_members",
+ notification_type="event",
+ event_date=event_date,
+ recipient_count=result["total_members"],
+ sent_count=result["discord_ok"],
+ failed_count=result["discord_fail"],
+ )
return summary
# Mode C: no event_date and no custom_message — list upcoming events
@@ -583,12 +680,24 @@ async def health_check(request):
Route("/health", health_check, methods=["GET"]),
]
- eprint(f"Starting MCP server (streamable-http) on {host}:{port}...")
- eprint(f"MCP endpoint: http://{host}:{port}/mcp")
- eprint(f"Health check: http://{host}:{port}/health")
+ log_event(
+ "service_started",
+ "mcp_server",
+ status="running",
+ service="mcp_server",
+ transport="streamable_http",
+ host=host,
+ port=port,
+ )
starlette_app = mcp.streamable_http_app()
uvicorn.run(starlette_app, host=host, port=port, log_level="info")
else:
- eprint("Starting MCP server in stdio mode...")
+ log_event(
+ "service_started",
+ "mcp_server",
+ status="running",
+ service="mcp_server",
+ transport="stdio",
+ )
mcp.run(transport="stdio")
diff --git a/src/ian/gateways/webhook_server.py b/src/ian/gateways/webhook_server.py
index 2f7db58..0485277 100644
--- a/src/ian/gateways/webhook_server.py
+++ b/src/ian/gateways/webhook_server.py
@@ -25,7 +25,7 @@
from ian.gateways import facebook_webhook, line_webhook
from ian.gateways.messaging_common import get_current_time
from ian.services.member_store import init as init_member_db
-from ian.utils.console import eprint
+from ian.utils.logging import log_event
app = Flask(__name__)
@@ -51,9 +51,21 @@ def initialize_dependencies() -> None:
"""Initialize external member data when the webhook server starts."""
try:
init_member_db()
- eprint("社員資料庫已初始化 (webhook_server)")
+ log_event(
+ "operation_completed",
+ "webhook_server",
+ status="success",
+ operation="initialize_member_store",
+ )
except Exception as e:
- eprint(f"社員資料庫初始化失敗: {e}")
+ log_event(
+ "operation_failed",
+ "webhook_server",
+ level="error",
+ status="error",
+ operation="initialize_member_store",
+ error=e,
+ )
@app.route('/', methods=['GET'])
@@ -75,7 +87,15 @@ async def webhook():
if data.get('object') == 'page':
facebook_webhook.handle_facebook_messages(data)
except Exception as e:
- print(f"Webhook 處理過程中發生未知錯誤: {e}")
+ log_event(
+ "request_failed",
+ "webhook_server",
+ level="error",
+ platform="Facebook",
+ status="error",
+ operation="handle_webhook",
+ error=e,
+ )
return "ok", 200
@@ -91,10 +111,25 @@ def line_callback():
try:
line_webhook.line_handler.handle(body, signature)
except InvalidSignatureError:
- eprint("LINE: Invalid signature")
+ log_event(
+ "request_rejected",
+ "webhook_server",
+ level="warning",
+ platform="LINE",
+ status="invalid_signature",
+ reason="signature_verification_failed",
+ )
abort(400)
except Exception as e:
- eprint(f"LINE CALLBACK: handler.handle() 發生例外: {e}")
+ log_event(
+ "request_failed",
+ "webhook_server",
+ level="error",
+ platform="LINE",
+ status="error",
+ operation="handle_webhook",
+ error=e,
+ )
return "OK", 200
@@ -110,5 +145,13 @@ def status():
def entrypoint(platform: str = "all"):
initialize_dependencies()
enabled = configure_platforms(platform)
- print(f"啟動 Flask 伺服器... platforms={', '.join(sorted(enabled))}")
+ log_event(
+ "service_started",
+ "webhook_server",
+ status="running",
+ service="webhook_server",
+ host="0.0.0.0",
+ port=5190,
+ enabled_platforms=sorted(enabled),
+ )
app.run(host='0.0.0.0', port=5190, debug=False)
diff --git a/src/ian/services/course_catalog.py b/src/ian/services/course_catalog.py
index a1f20cb..072dd8c 100644
--- a/src/ian/services/course_catalog.py
+++ b/src/ian/services/course_catalog.py
@@ -35,7 +35,7 @@
normalize_date,
parse_dates_from_query,
)
-from ian.utils.console import eprint
+from ian.utils.logging import elapsed_ms, log_event
course_data = None
@@ -76,10 +76,22 @@ def _load_from_cache() -> pd.DataFrame:
df = pd.read_csv(COURSE_CACHE_FILE)
course_data = df
last_course_update = _get_cache_timestamp()
- eprint(f"[快取] 從本地快取載入課程資料,共 {len(df)} 筆記錄")
+ log_event(
+ "cache_loaded",
+ "course_catalog",
+ status="success",
+ record_count=len(df),
+ )
return df
except Exception as e:
- eprint(f"[快取] 讀取快取失敗: {e}")
+ log_event(
+ "cache_failed",
+ "course_catalog",
+ level="warning",
+ status="failure",
+ operation="load",
+ error=e,
+ )
return None
@@ -88,15 +100,31 @@ def _save_to_cache(df: pd.DataFrame, timestamp: float):
os.makedirs(str(CACHE_DIR), exist_ok=True)
df.to_csv(COURSE_CACHE_FILE, index=False, encoding="utf-8")
_save_cache_timestamp(timestamp)
- eprint("[快取] 課程資料已儲存到本地快取")
+ log_event("cache_saved", "course_catalog", status="success")
except Exception as e:
- eprint(f"[快取] 儲存快取失敗: {e}")
+ log_event(
+ "cache_failed",
+ "course_catalog",
+ level="warning",
+ status="failure",
+ operation="save",
+ error=e,
+ )
def _fetch_from_url(url: str, max_retries: int = 3) -> pd.DataFrame:
for attempt in range(max_retries):
+ attempt_number = attempt + 1
+ started_at = time.monotonic()
try:
- eprint(f"[網路] 正在從 Google Sheets 載入課程資料... (嘗試 {attempt + 1}/{max_retries})")
+ log_event(
+ "external_fetch_started",
+ "course_catalog",
+ status="started",
+ source="course_data",
+ attempt=attempt_number,
+ max_attempts=max_retries,
+ )
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
}
@@ -106,7 +134,12 @@ def _fetch_from_url(url: str, max_retries: int = 3) -> pd.DataFrame:
df = pd.read_csv(io.StringIO(response.text))
if any(r"\x" in str(col) for col in df.columns):
- eprint("偵測到編碼問題,嘗試修復...")
+ log_event(
+ "encoding_repair_started",
+ "course_catalog",
+ status="started",
+ source="course_data",
+ )
try:
response_bytes = requests.get(url, headers=headers, timeout=30).content
for encoding in ["utf-8", "utf-8-sig", "big5", "gb2312"]:
@@ -114,38 +147,81 @@ def _fetch_from_url(url: str, max_retries: int = 3) -> pd.DataFrame:
decoded_text = response_bytes.decode(encoding)
df = pd.read_csv(io.StringIO(decoded_text))
if not any(r"\x" in str(col) for col in df.columns):
- eprint(f"編碼修復成功,使用編碼: {encoding}")
+ log_event(
+ "encoding_repair_completed",
+ "course_catalog",
+ status="success",
+ encoding=encoding,
+ )
break
except (UnicodeDecodeError, UnicodeError):
continue
else:
- eprint("無法自動修復編碼問題")
+ log_event(
+ "encoding_repair_failed",
+ "course_catalog",
+ level="warning",
+ status="failure",
+ reason="unsupported_encoding",
+ )
except Exception as e:
- eprint(f"編碼修復失敗: {e}")
-
- eprint(f"[網路] 課程資料載入成功,共 {len(df)} 筆記錄")
+ log_event(
+ "encoding_repair_failed",
+ "course_catalog",
+ level="warning",
+ status="failure",
+ error=e,
+ )
+
+ log_event(
+ "external_fetch_completed",
+ "course_catalog",
+ status="success",
+ duration_ms=elapsed_ms(started_at),
+ source="course_data",
+ attempt=attempt_number,
+ record_count=len(df),
+ )
return df
except requests.exceptions.RequestException as e:
- eprint(f"網路請求錯誤 (嘗試 {attempt + 1}/{max_retries}): {e}")
+ _log_fetch_failure(started_at, attempt_number, max_retries, e)
if attempt < max_retries - 1:
time.sleep(2**attempt)
except pd.errors.EmptyDataError as e:
- eprint(f"CSV 資料為空 (嘗試 {attempt + 1}/{max_retries}): {e}")
+ _log_fetch_failure(started_at, attempt_number, max_retries, e)
if attempt < max_retries - 1:
time.sleep(2**attempt)
except pd.errors.ParserError as e:
- eprint(f"CSV 解析錯誤 (嘗試 {attempt + 1}/{max_retries}): {e}")
+ _log_fetch_failure(started_at, attempt_number, max_retries, e)
if attempt < max_retries - 1:
time.sleep(2**attempt)
except Exception as e:
- eprint(f"載入課程資料時發生未預期錯誤 (嘗試 {attempt + 1}/{max_retries}): {type(e).__name__} - {e}")
+ _log_fetch_failure(started_at, attempt_number, max_retries, e)
if attempt < max_retries - 1:
time.sleep(2**attempt)
- eprint("[網路] 所有嘗試均失敗,無法載入課程資料")
return None
+def _log_fetch_failure(
+ started_at: float,
+ attempt: int,
+ max_attempts: int,
+ error: Exception,
+) -> None:
+ log_event(
+ "external_fetch_failure",
+ "course_catalog",
+ level="warning",
+ status="retrying" if attempt < max_attempts else "failure",
+ duration_ms=elapsed_ms(started_at),
+ source="course_data",
+ attempt=attempt,
+ max_attempts=max_attempts,
+ error=error,
+ )
+
+
def load_course_data_from_url(url: str, max_retries: int = 3) -> pd.DataFrame:
global course_data, last_course_update
@@ -153,9 +229,21 @@ def load_course_data_from_url(url: str, max_retries: int = 3) -> pd.DataFrame:
return _load_from_cache()
if not url:
- eprint("[設定] COURSE_DATA_URL is not configured")
+ log_event(
+ "configuration_invalid",
+ "course_catalog",
+ level="warning",
+ status="missing",
+ setting="course_data_url",
+ )
if os.path.exists(COURSE_CACHE_FILE):
- eprint("[快取] 使用過期的本地快取")
+ log_event(
+ "cache_fallback",
+ "course_catalog",
+ level="warning",
+ status="stale",
+ reason="missing_configuration",
+ )
return _load_from_cache()
return None
@@ -169,7 +257,13 @@ def load_course_data_from_url(url: str, max_retries: int = 3) -> pd.DataFrame:
return df
if os.path.exists(COURSE_CACHE_FILE):
- eprint("[快取] 網路載入失敗,使用過期的本地快取")
+ log_event(
+ "cache_fallback",
+ "course_catalog",
+ level="warning",
+ status="stale",
+ reason="external_fetch_failed",
+ )
return _load_from_cache()
return None
diff --git a/src/ian/utils/console.py b/src/ian/utils/console.py
deleted file mode 100644
index a35a2c2..0000000
--- a/src/ian/utils/console.py
+++ /dev/null
@@ -1,26 +0,0 @@
-#
-# 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 .
-#
-
-import sys
-
-
-def eprint(*args, **kwargs):
- """Print to stderr."""
- print(*args, file=sys.stderr, **kwargs)
diff --git a/tests/gateways/test_mcp_server.py b/tests/gateways/test_mcp_server.py
index 911e742..d118063 100644
--- a/tests/gateways/test_mcp_server.py
+++ b/tests/gateways/test_mcp_server.py
@@ -19,6 +19,7 @@
#
import asyncio
+import json
import pytest
@@ -131,6 +132,14 @@ def test_generate_checkin_code_handles_member_and_guest_flows(
def test_member_tool_wrappers_return_messages_and_handle_exceptions(
monkeypatch, tool_name, dependency_name, args, error_prefix
):
+ events = []
+ monkeypatch.setattr(
+ mcp_server,
+ "log_event",
+ lambda event, component, **fields: events.append(
+ {"event": event, "component": component, **fields}
+ ),
+ )
tool = getattr(mcp_server, tool_name)
monkeypatch.setattr(
mcp_server, dependency_name, lambda *_: {"message": "service message"}
@@ -142,6 +151,19 @@ def fail(*_args):
monkeypatch.setattr(mcp_server, dependency_name, fail)
assert _run(tool(*args)) == f"{error_prefix}:service unavailable"
+ assert events == [
+ {
+ "event": "operation_failed",
+ "component": "mcp_server",
+ "level": "error",
+ "platform": "Discord",
+ "status": "error",
+ "operation": tool_name,
+ "account_id": "account-1",
+ "error": events[0]["error"],
+ }
+ ]
+ assert isinstance(events[0]["error"], RuntimeError)
def test_notify_members_rejects_non_staff_before_loading_data(monkeypatch):
@@ -258,3 +280,60 @@ def test_notify_members_lists_upcoming_events(monkeypatch, upcoming, expected_pa
result = _run(mcp_server.notify_members("部員"))
assert all(part in result for part in expected_parts)
+
+
+def test_course_retriever_log_redacts_query_and_identifiers(monkeypatch, capsys):
+ monkeypatch.setattr(
+ mcp_server.course_catalog,
+ "load_course_data_from_url",
+ lambda *_: None,
+ )
+ monkeypatch.setattr(
+ mcp_server,
+ "check_user_permission",
+ lambda *_: (True, "社員"),
+ )
+ monkeypatch.setattr(
+ mcp_server.course_catalog,
+ "get_all_course_data",
+ lambda *_: "course data",
+ )
+
+ result = _run(
+ mcp_server.search_course_chunks_by_semantics(
+ "Discord",
+ "private-account",
+ "",
+ "private-channel",
+ )
+ )
+
+ captured = capsys.readouterr()
+ assert result.startswith("course data")
+ assert captured.out == ""
+ entry = json.loads(captured.err)
+ assert entry["event"] == "tool_invoked"
+ assert entry["account_id"].startswith("sha256:")
+ assert entry["channel_id"].startswith("sha256:")
+ assert "private-account" not in json.dumps(entry)
+ assert "private-channel" not in json.dumps(entry)
+
+
+def test_stdio_entrypoint_emits_structured_log_without_stdout(monkeypatch, capsys):
+ calls = []
+ monkeypatch.setattr(mcp_server, "initialize_dependencies", lambda: None)
+ monkeypatch.setattr(
+ mcp_server.mcp,
+ "run",
+ lambda **kwargs: calls.append(kwargs),
+ )
+
+ mcp_server.entrypoint(http=False)
+
+ captured = capsys.readouterr()
+ assert captured.out == ""
+ assert calls == [{"transport": "stdio"}]
+ entry = json.loads(captured.err)
+ assert entry["event"] == "service_started"
+ assert entry["component"] == "mcp_server"
+ assert entry["transport"] == "stdio"
diff --git a/tests/gateways/test_webhook_server.py b/tests/gateways/test_webhook_server.py
index e0ad946..46ad5e8 100644
--- a/tests/gateways/test_webhook_server.py
+++ b/tests/gateways/test_webhook_server.py
@@ -18,8 +18,20 @@
# along with Ian. If not, see .
#
-from ian.gateways import facebook_webhook, line_webhook, webhook_server
+import json
+import warnings
+
+import pytest
+from linebot.exceptions import InvalidSignatureError
+
from ian.config import MEMBER_MAPPING_FILE
+from ian.gateways import facebook_webhook, line_webhook, webhook_server
+
+
+def _invalid_signature_error():
+ with warnings.catch_warnings():
+ warnings.simplefilter("ignore")
+ return InvalidSignatureError("private signature")
def test_webhook_post_delegates_facebook_messages(monkeypatch):
@@ -119,3 +131,58 @@ def test_facebook_member_mapping_path_comes_from_config():
assert facebook_webhook.MAPPING_FILE_PATH == MEMBER_MAPPING_FILE
assert MEMBER_MAPPING_FILE.name == "member_mapping.csv"
assert MEMBER_MAPPING_FILE.parent.name == "data"
+
+
+@pytest.mark.parametrize(
+ ("error_factory", "expected_event", "expected_status", "response_status"),
+ [
+ pytest.param(
+ _invalid_signature_error,
+ "request_rejected",
+ "invalid_signature",
+ 400,
+ id="invalid-signature",
+ ),
+ pytest.param(
+ lambda: RuntimeError("private callback body"),
+ "request_failed",
+ "error",
+ 200,
+ id="handler-failure",
+ ),
+ ],
+)
+def test_line_callback_emits_structured_failure_event(
+ monkeypatch,
+ capsys,
+ error_factory,
+ expected_event,
+ expected_status,
+ response_status,
+):
+ error = error_factory()
+
+ def fail(*_args):
+ raise error
+
+ monkeypatch.setattr(line_webhook.line_handler, "handle", fail)
+ webhook_server.configure_platforms("line")
+
+ try:
+ response = webhook_server.app.test_client().post(
+ "/line/callback",
+ data="private callback body",
+ headers={"X-Line-Signature": "private signature"},
+ )
+ finally:
+ webhook_server.configure_platforms("all")
+
+ captured = capsys.readouterr()
+ entry = json.loads(captured.err.strip().splitlines()[-1])
+ assert response.status_code == response_status
+ assert entry["event"] == expected_event
+ assert entry["component"] == "webhook_server"
+ assert entry["platform"] == "LINE"
+ assert entry["status"] == expected_status
+ assert "private callback body" not in captured.err
+ assert "private signature" not in captured.err
diff --git a/tests/services/test_course_catalog.py b/tests/services/test_course_catalog.py
index 1616579..ee2c11f 100644
--- a/tests/services/test_course_catalog.py
+++ b/tests/services/test_course_catalog.py
@@ -22,6 +22,8 @@
from datetime import datetime
import pandas as pd
+import pytest
+import requests
from ian.config import TZ_TPE
from ian.services import course_catalog
@@ -138,3 +140,69 @@ def test_load_course_data_uses_valid_cache(monkeypatch, tmp_path):
assert list(df["社課主題/活動名稱"]) == ["生成式 AI"]
assert course_catalog.course_data is df
+
+
+@pytest.mark.parametrize(
+ ("max_retries", "expected_statuses"),
+ [
+ pytest.param(1, ["failure"], id="single-attempt"),
+ pytest.param(2, ["retrying", "failure"], id="retry-then-fail"),
+ ],
+)
+def test_fetch_failure_events_report_retry_state(
+ monkeypatch,
+ max_retries,
+ expected_statuses,
+):
+ events = []
+
+ def fail(*_args, **_kwargs):
+ raise requests.RequestException("private URL details")
+
+ monkeypatch.setattr(course_catalog.requests, "get", fail)
+ monkeypatch.setattr(course_catalog.time, "sleep", lambda *_: None)
+ monkeypatch.setattr(
+ course_catalog,
+ "log_event",
+ lambda event, component, **fields: events.append(
+ {"event": event, "component": component, **fields}
+ ),
+ )
+
+ assert course_catalog._fetch_from_url("https://private.example", max_retries) is None
+
+ failures = [event for event in events if event["event"] == "external_fetch_failure"]
+ assert [event["status"] for event in failures] == expected_statuses
+ assert [event["attempt"] for event in failures] == list(
+ range(1, max_retries + 1)
+ )
+ assert all(event["component"] == "course_catalog" for event in failures)
+ assert all(event["max_attempts"] == max_retries for event in failures)
+
+
+def test_missing_course_url_emits_configuration_event(monkeypatch, tmp_path):
+ events = []
+ monkeypatch.setattr(
+ course_catalog,
+ "COURSE_CACHE_FILE",
+ str(tmp_path / "missing.csv"),
+ )
+ monkeypatch.setattr(course_catalog, "_is_cache_valid", lambda: False)
+ monkeypatch.setattr(
+ course_catalog,
+ "log_event",
+ lambda event, component, **fields: events.append(
+ {"event": event, "component": component, **fields}
+ ),
+ )
+
+ assert course_catalog.load_course_data_from_url("") is None
+ assert events == [
+ {
+ "event": "configuration_invalid",
+ "component": "course_catalog",
+ "level": "warning",
+ "status": "missing",
+ "setting": "course_data_url",
+ }
+ ]
diff --git a/tests/test_console.py b/tests/test_console.py
deleted file mode 100644
index c975169..0000000
--- a/tests/test_console.py
+++ /dev/null
@@ -1,29 +0,0 @@
-#
-# 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 .
-#
-
-from ian.utils.console import eprint
-
-
-def test_eprint_writes_to_stderr(capsys):
- eprint("hello", "stderr")
-
- captured = capsys.readouterr()
- assert captured.out == ""
- assert captured.err == "hello stderr\n"