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"