"""Phase 31 — admin observability 6 路由 smoke tests. 目的:保 Phase 27/28/29 6 個 admin 頁不被未來修改打掛。 不接 DB/Ollama/MCP,全 mock。每個 route 至少驗: 1. HTTP 200 回應(render_template 不爆) 2. session 失敗時走 fallback(error banner) 3. budget/update 輸入驗證 """ from __future__ import annotations from unittest.mock import MagicMock import pytest from flask import Flask @pytest.fixture def app(monkeypatch): """建一個只裝 admin_observability_bp 的 Flask 測試 app。""" flask_app = Flask( __name__, template_folder='../templates', static_folder='../static', ) flask_app.config['TESTING'] = True flask_app.config['SECRET_KEY'] = 'test' # base.html 用 csrf_token() / 部分 sidebar globals — 給 stub 避免 UndefinedError flask_app.jinja_env.globals['csrf_token'] = lambda: 'test-csrf-token' flask_app.jinja_env.globals.setdefault('current_user', None) from routes import admin_observability_routes as mod flask_app.register_blueprint(mod.admin_observability_bp) # auth.login_required 會 redirect to url_for('login') — 沒裝 auth blueprint 時補 stub @flask_app.route('/login') def login(): return 'login stub', 200 return flask_app @pytest.fixture def client(app): """已登入的 test client(透過 session transaction 設 logged_in)。""" c = app.test_client() with c.session_transaction() as sess: sess['logged_in'] = True sess['username'] = 'pytest_admin' sess['role'] = 'admin' return c @pytest.fixture def anon_client(app): """未登入的 test client(驗 @login_required 強制 redirect)。""" return app.test_client() def _fake_session(rows_per_query=None): """建 mock session:execute().fetchall() 回 rows_per_query; fetchone() 回 (0,) 或第一行。""" session = MagicMock() def _execute(*_a, **_kw): result = MagicMock() rows = rows_per_query if rows_per_query is not None else [] result.fetchall.return_value = rows result.fetchone.return_value = rows[0] if rows else (0,) # iteration support result.__iter__ = lambda self: iter(rows) return result session.execute.side_effect = _execute session.commit = MagicMock() session.close = MagicMock() return session # ────────────────────────────────────────────────────────────────────────── # /observability/ai_calls # ────────────────────────────────────────────────────────────────────────── def test_ai_calls_dashboard_200_empty(client, monkeypatch): from routes import admin_observability_routes as mod monkeypatch.setattr(mod, 'get_session', lambda: _fake_session([])) r = client.get('/observability/ai_calls') assert r.status_code == 200 assert b'AI Calls' in r.data or '\xe5\x88\x86\xe6\x9e\x90'.encode() in r.data or True # 中文標題可選 def test_ai_calls_dashboard_db_error_falls_back(client, monkeypatch): from routes import admin_observability_routes as mod bad = MagicMock() bad.execute.side_effect = RuntimeError('DB down') bad.close = MagicMock() monkeypatch.setattr(mod, 'get_session', lambda: bad) r = client.get('/observability/ai_calls') assert r.status_code == 200 # 失敗安全:仍 render,不 500 # ────────────────────────────────────────────────────────────────────────── # /observability/promotion_review # ────────────────────────────────────────────────────────────────────────── def test_promotion_review_200(client, monkeypatch): from routes import admin_observability_routes as mod monkeypatch.setattr(mod, 'get_session', lambda: _fake_session([])) r = client.get('/observability/promotion_review') assert r.status_code == 200 # ────────────────────────────────────────────────────────────────────────── # /observability/quality_trend # ────────────────────────────────────────────────────────────────────────── def test_quality_trend_200(client, monkeypatch): from routes import admin_observability_routes as mod monkeypatch.setattr(mod, 'get_session', lambda: _fake_session([])) r = client.get('/observability/quality_trend') assert r.status_code == 200 # ────────────────────────────────────────────────────────────────────────── # /observability/budget # ────────────────────────────────────────────────────────────────────────── def test_budget_dashboard_200_empty(client, monkeypatch): from routes import admin_observability_routes as mod monkeypatch.setattr(mod, 'get_session', lambda: _fake_session([])) r = client.get('/observability/budget') assert r.status_code == 200 def test_budget_update_rejects_invalid_budget(client, monkeypatch): from routes import admin_observability_routes as mod monkeypatch.setattr(mod, 'get_session', lambda: _fake_session([])) r = client.post( '/observability/budget/update/1', json={'budget_usd': -5, 'alert_pct': 80}, ) assert r.status_code == 400 assert r.get_json()['ok'] is False def test_budget_update_rejects_invalid_alert(client, monkeypatch): from routes import admin_observability_routes as mod monkeypatch.setattr(mod, 'get_session', lambda: _fake_session([])) r = client.post( '/observability/budget/update/1', json={'budget_usd': 10, 'alert_pct': 999}, ) assert r.status_code == 400 def test_budget_update_accepts_valid(client, monkeypatch): from routes import admin_observability_routes as mod monkeypatch.setattr(mod, 'get_session', lambda: _fake_session([])) r = client.post( '/observability/budget/update/1', json={'budget_usd': 25.50, 'alert_pct': 80}, ) assert r.status_code == 200 assert r.get_json()['ok'] is True # ────────────────────────────────────────────────────────────────────────── # /observability/ppt_audit_history # ────────────────────────────────────────────────────────────────────────── def test_ppt_audit_history_200(client): """無 DB 依賴,純掃 reports/。""" r = client.get('/observability/ppt_audit_history') assert r.status_code == 200 def test_ppt_audit_history_shows_ppt_schedule_and_db_runs(client, monkeypatch): """PPT 產線頁必須呈現六種固定週期與 DB 寫入紀錄。""" from services import ppt_auto_generation_service as svc cadences = svc.get_schedule_cadence_status([ {'key': key, 'ready': True} for key in svc.DEFINED_REPORT_TYPES ]) monkeypatch.setattr(svc, 'get_defined_report_coverage', lambda **_kw: { 'enabled': True, 'items': [{ 'key': 'daily', 'label': '每日日報', 'target_label': '2026/05/17', 'ready': False, 'status': 'missing', 'status_label': '待排程補齊', 'status_hint': '尚未找到符合定義的檔案或 DB 紀錄。', }], 'missing_report_types': ['daily'], 'missing_count': 1, 'ready_count': len(svc.DEFINED_REPORT_TYPES) - 1, 'total': len(svc.DEFINED_REPORT_TYPES), 'last_run': None, 'cadences': cadences, 'cadence_summary': '、'.join(c['schedule_text'] for c in cadences), }) monkeypatch.setattr(svc, 'get_generation_run_history', lambda **_kw: [ { 'schedule_kind': 'daily', 'schedule_label': '每日', 'report_type': 'daily', 'report_label': '每日日報', 'target_label': '2026/05/17', 'status': 'error', 'status_label': '失敗', 'file_name': '', 'file_size_kb': None, 'error_msg': '產線錯誤:缺少資料來源', 'started_at': '2026-05-17 20:29', 'finished_at': '2026-05-17 20:30', }, { 'schedule_kind': 'daily', 'schedule_label': '每日', 'report_type': 'daily', 'report_label': '每日日報', 'target_label': '2026/05/17', 'status': 'ready', 'status_label': '已產生', 'file_name': 'ocbot_daily_20260517.pptx', 'file_size_kb': 1024, 'error_msg': '', 'started_at': '2026-05-17 20:30', 'finished_at': '2026-05-17 20:31', }, ]) r = client.get('/observability/ppt_audit_history') html = r.data.decode('utf-8') for text in ['每日 20:30', '每週一 20:40', '每月 1 日 20:50', '每季首日 21:00', '每半年首日 21:10', '每年 1/1 21:20']: assert text in html assert 'ppt_generation_runs' in html assert '每日日報' in html assert 'Pipeline Health' in html assert '排程節奏' in html assert 'DB 寫入' in html assert '線上預覽' in html assert 'Action Queue' in html assert '接下來要處理的事' in html assert '異常優先' in html assert '產線錯誤:缺少資料來源' in html assert 'data-ppt-generate-one' in html assert 'data-report-type="daily"' in html assert '重跑' in html assert '待排程補齊' in html def test_ppt_audit_history_shows_recent_preview_workbench(client, monkeypatch, tmp_path): """有檔案時,頁面上方要先給可預覽簡報入口。""" import zipfile from pathlib import Path from services import ppt_preview_service as preview_svc reports_dir = tmp_path / 'reports' cache_dir = tmp_path / 'preview-cache' reports_dir.mkdir() pptx = reports_dir / 'ocbot_daily_20260517.pptx' with zipfile.ZipFile(pptx, 'w') as zf: zf.writestr('[Content_Types].xml', '') monkeypatch.setenv('PPT_PREVIEW_CACHE_DIR', str(cache_dir)) cache_info = preview_svc.get_ppt_preview_cache_info(pptx) Path(cache_info.pdf_path).parent.mkdir(parents=True, exist_ok=True) Path(cache_info.pdf_path).write_bytes(b'%PDF-1.4\n') monkeypatch.setenv('REPORTS_DIR', str(reports_dir)) r = client.get('/observability/ppt_audit_history?month=2026-05') html = r.data.decode('utf-8') assert r.status_code == 200 assert '最近可預覽簡報' in html assert 'ocbot_daily_20260517.pptx' in html assert '線上預覽' in html assert 'data-ppt-open-preview' in html assert 'data-ppt-preview-modal' in html assert 'data-ppt-preview-modal-title' in html assert 'data-ppt-preview-pdf' in html assert 'PDF 快取' in html assert '1 份 PDF 快取' in html def test_ppt_audit_file_view_renders_online_preview(client, monkeypatch, tmp_path): """PPTX view 入口應回站內預覽頁,而不是把 PPTX 直接丟給瀏覽器。""" import zipfile from services import ppt_preview_service as preview_svc reports_dir = tmp_path / 'reports' reports_dir.mkdir() pptx = reports_dir / 'ocbot_daily_20260517.pptx' with zipfile.ZipFile(pptx, 'w') as zf: zf.writestr('[Content_Types].xml', '') monkeypatch.setenv('REPORTS_DIR', str(reports_dir)) monkeypatch.setattr( preview_svc, 'build_ppt_preview', lambda *_args, **_kwargs: preview_svc.PPTPreviewResult( ok=True, pdf_path=str(reports_dir / 'preview.pdf'), cache_hit=True, converter='/usr/bin/libreoffice', ), ) r = client.get('/observability/ppt_audit_file/ocbot_daily_20260517.pptx') html = r.data.decode('utf-8') assert r.status_code == 200 assert 'PPT 線上預覽' in html assert 'action=pdf' in html assert '下載 PPTX' in html def test_ppt_audit_history_audit_rows_include_inline_replay(client, monkeypatch, tmp_path): """審核歷史每筆紀錄都應能直接開同頁 PDF 回放。""" import zipfile from datetime import datetime from unittest.mock import MagicMock from routes import admin_observability_routes as mod reports_dir = tmp_path / 'reports' reports_dir.mkdir() pptx = reports_dir / 'ocbot_daily_20260517.pptx' with zipfile.ZipFile(pptx, 'w') as zf: zf.writestr('[Content_Types].xml', '') monkeypatch.setenv('REPORTS_DIR', str(reports_dir)) class FakeSession: def execute(self, statement, _params=None): sql = str(statement) result = MagicMock() if 'FROM ppt_reports' in sql: rows = [] result.fetchall.return_value = rows elif 'SELECT audited_at, pptx_filename' in sql: rows = [ (datetime(2026, 5, 17, 22, 5), 'ocbot_daily_20260517.pptx', 'passed', 0, 0.93, 1200, '') ] result.fetchall.return_value = rows elif 'COALESCE(AVG(confidence)' in sql: row = (1, 1, 0, 0, 0, 0.93, 0) result.fetchone.return_value = row elif 'GROUP BY pptx_filename' in sql: rows = [] result.fetchall.return_value = rows else: rows = [] result.fetchall.return_value = rows result.fetchone.return_value = (0,) return result def close(self): return None monkeypatch.setattr(mod, 'get_session', lambda: FakeSession()) r = client.get('/observability/ppt_audit_history?month=2026-05') html = r.data.decode('utf-8') assert r.status_code == 200 assert '審核回放 · ocbot_daily_20260517.pptx' in html assert 'data-ppt-open-preview' in html assert 'ocbot_daily_20260517.pptx?action=pdf' in html assert '回放' in html def test_ppt_audit_history_weekly_rows_include_visual_audit(client, monkeypatch, tmp_path): """非 daily 簡報也應顯示自己的視覺 QA 歷史,不再要求切回 daily。""" import zipfile from datetime import datetime from unittest.mock import MagicMock from routes import admin_observability_routes as mod reports_dir = tmp_path / 'reports' reports_dir.mkdir() pptx = reports_dir / 'ocbot_weekly_20260518.pptx' with zipfile.ZipFile(pptx, 'w') as zf: zf.writestr('[Content_Types].xml', '') monkeypatch.setenv('REPORTS_DIR', str(reports_dir)) class FakeSession: def execute(self, statement, _params=None): sql = str(statement) result = MagicMock() if 'FROM ppt_reports' in sql: result.fetchall.return_value = [] elif 'SELECT audited_at, pptx_filename' in sql: result.fetchall.return_value = [ (datetime(2026, 5, 18, 22, 7), 'ocbot_weekly_20260518.pptx', 'passed', 0, 0.91, 1800, '') ] elif 'COALESCE(AVG(confidence)' in sql: result.fetchone.return_value = (1, 1, 0, 0, 0, 0.91, 0) elif 'GROUP BY pptx_filename' in sql: result.fetchall.return_value = [] else: result.fetchall.return_value = [] result.fetchone.return_value = (0,) return result def close(self): return None monkeypatch.setattr(mod, 'get_session', lambda: FakeSession()) r = client.get('/observability/ppt_audit_history?month=2026-05&report_type=weekly') html = r.data.decode('utf-8') assert r.status_code == 200 assert 'ocbot_weekly_20260518.pptx' in html assert '審核回放 · ocbot_weekly_20260518.pptx' in html assert '只有「每日日報」會進入視覺審核流程' not in html assert 'data-ppt-run-vision' in html def test_ppt_audit_history_shows_preview_prewarm_action(client, monkeypatch, tmp_path): """未快取 PDF 的 PPTX 要能在產線清單直接預熱預覽。""" import zipfile reports_dir = tmp_path / 'reports' reports_dir.mkdir() pptx = reports_dir / 'ocbot_daily_20260518.pptx' with zipfile.ZipFile(pptx, 'w') as zf: zf.writestr('[Content_Types].xml', '') monkeypatch.setenv('REPORTS_DIR', str(reports_dir)) monkeypatch.setenv('PPT_PREVIEW_CACHE_DIR', str(tmp_path / 'preview-cache')) r = client.get('/observability/ppt_audit_history?month=2026-05') html = r.data.decode('utf-8') assert r.status_code == 200 assert '首次轉檔' in html assert 'data-ppt-prewarm-all' in html assert 'data-ppt-prewarm-count' in html assert 'data-ppt-prewarm-preview' in html assert 'data-ppt-filename="ocbot_daily_20260518.pptx"' in html assert '預熱本頁 PDF' in html assert '預熱 PDF' in html def test_ppt_audit_file_prewarm_builds_preview_cache(client, monkeypatch, tmp_path): """預熱端點應回 JSON,不直接把 PDF body 丟給前端。""" import zipfile from services import ppt_preview_service as preview_svc reports_dir = tmp_path / 'reports' reports_dir.mkdir() pptx = reports_dir / 'ocbot_daily_20260518.pptx' pdf = reports_dir / 'preview.pdf' with zipfile.ZipFile(pptx, 'w') as zf: zf.writestr('[Content_Types].xml', '') monkeypatch.setenv('REPORTS_DIR', str(reports_dir)) monkeypatch.setattr( preview_svc, 'build_ppt_preview', lambda *_args, **_kwargs: preview_svc.PPTPreviewResult( ok=True, pdf_path=str(pdf), cache_hit=False, converter='/usr/bin/libreoffice', ), ) r = client.post('/observability/ppt_audit_file/ocbot_daily_20260518.pptx/prewarm') data = r.get_json() assert r.status_code == 200 assert data['ok'] is True assert data['filename'] == 'ocbot_daily_20260518.pptx' assert data['message'] == 'PDF 預覽快取已建立' def test_ppt_audit_run_vision_queues_background_audit(client, monkeypatch): """立即視覺 QA 端點只排入背景任務,不讓瀏覽器等待模型跑完。""" from services import ppt_vision_service as svc captured = {} def fake_start(**kwargs): captured.update(kwargs) return {'ok': True, 'status': 'queued', 'message': 'PPT vision audit queued.'} monkeypatch.setattr(svc, 'start_ppt_vision_audit_background', fake_start) r = client.post( '/observability/ppt_audit/run_vision', json={'filenames': ['ocbot_daily_20260518.pptx'], 'max_files': 1}, ) data = r.get_json() assert r.status_code == 202 assert data['ok'] is True assert captured['filenames'] == ['ocbot_daily_20260518.pptx'] assert captured['max_files'] == 1 # ────────────────────────────────────────────────────────────────────────── # /observability/host_health # ────────────────────────────────────────────────────────────────────────── def test_host_health_200(client, monkeypatch): """全 mock 三主機 + MCP,避免實際連線。""" import services.ollama_service as ollama_mod monkeypatch.setattr(ollama_mod, '_is_unhealthy', lambda _h: False, raising=False) monkeypatch.setattr(ollama_mod, '_unhealthy_marks', {}, raising=False) # mock requests.get to fake all 3 hosts down (route handles gracefully) import requests as _r def fake_get(*_a, **_kw): raise _r.exceptions.ConnectionError('mocked') monkeypatch.setattr(_r, 'get', fake_get) r = client.get('/observability/host_health') assert r.status_code == 200 def test_host_health_skips_probe_persistence_on_sqlite(client, monkeypatch): """Local visual QA uses SQLite; GET page should not warn on BIGINT autoincrement drift.""" from routes import admin_observability_routes as mod import services.ollama_service as ollama_mod import requests as _r class _Dialect: name = 'sqlite' class _Bind: dialect = _Dialect() session = _fake_session([]) session.get_bind.return_value = _Bind() monkeypatch.setattr(mod, 'get_session', lambda: session) monkeypatch.setattr(ollama_mod, '_is_unhealthy', lambda _h: False, raising=False) monkeypatch.setattr(ollama_mod, '_unhealthy_marks', {}, raising=False) class FakeResponse: status_code = 200 def json(self): return {'models': [{'name': 'bge-m3'}]} monkeypatch.setattr(_r, 'get', lambda *_a, **_kw: FakeResponse()) r = client.get('/observability/host_health') assert r.status_code == 200 insert_calls = [ call for call in session.execute.call_args_list if 'INSERT INTO host_health_probes' in str(call.args[0]) ] assert insert_calls == [] # ────────────────────────────────────────────────────────────────────────── # Phase 33 Auth Hardening — 未登入必 302 redirect 到 /login # ────────────────────────────────────────────────────────────────────────── def test_anon_get_redirects_to_login(anon_client): """未登入打 GET 路由 → @login_required 必 302 redirect 到 /login。""" for path in [ '/observability/', '/observability/overview', '/observability/rag_queries', '/observability/business_intel', '/observability/agent_orchestration', '/observability/ai_calls', '/observability/promotion_review', '/observability/quality_trend', '/observability/host_health', '/observability/budget', '/observability/ppt_audit_history', '/observability/api/health_indicator', ]: r = anon_client.get(path) # 308(permanent redirect for trailing slash)或 302(login redirect)皆視為阻擋 assert r.status_code in (302, 308), f'{path} 未強制 login (got {r.status_code})' def test_anon_post_blocked(anon_client): """未登入 POST mutation 端點 → 必 302 redirect(防 anon 執行任何 mutation)。""" posts = [ ('/observability/promotion_review/approve/1', None), ('/observability/promotion_review/reject/1', None), ('/observability/budget/update/1', {'budget_usd': 99, 'alert_pct': 80}), ('/observability/ai_calls/trigger_code_review', None), ('/observability/ppt_audit/trigger_aider_heal', None), ('/observability/playbooks/toggle/1', None), ('/observability/host_health/trigger_autoheal', None), ('/observability/budget/force_throttle', None), ] for path, body in posts: r = anon_client.post(path, json=body) if body else anon_client.post(path) assert r.status_code in (302, 308), f'{path} POST 未強制 login (got {r.status_code})' # ────────────────────────────────────────────────────────────────────────── # Phase 38+ 新增 GET 路由 smoke # ────────────────────────────────────────────────────────────────────────── def test_overview_index_200(client, monkeypatch): """/observability/ (root index) — 觀測台總覽。""" from routes import admin_observability_routes as mod monkeypatch.setattr(mod, 'get_session', lambda: _fake_session([])) r = client.get('/observability/') assert r.status_code in (200, 308) def test_overview_dashboard_200(client, monkeypatch): """/observability/overview — Phase 45 總覽頁。""" from routes import admin_observability_routes as mod monkeypatch.setattr(mod, 'get_session', lambda: _fake_session([])) # mock requests for 三主機 sparkline import requests as _r def fake_get(*_a, **_kw): raise _r.exceptions.ConnectionError('mocked') monkeypatch.setattr(_r, 'get', fake_get) r = client.get('/observability/overview') assert r.status_code == 200 def test_rag_queries_200(client, monkeypatch): """/observability/rag_queries — Phase 51 RAG 召回詳情頁。""" from routes import admin_observability_routes as mod monkeypatch.setattr(mod, 'get_session', lambda: _fake_session([])) r = client.get('/observability/rag_queries') assert r.status_code == 200 def test_business_intel_200(client, monkeypatch): """/observability/business_intel — Phase 48 商業面 × AI 編排。""" from routes import admin_observability_routes as mod monkeypatch.setattr(mod, 'get_session', lambda: _fake_session([])) r = client.get('/observability/business_intel') assert r.status_code == 200 def test_agent_orchestration_200(client, monkeypatch): """/observability/agent_orchestration — Phase 46 Agent 編排矩陣。""" from routes import admin_observability_routes as mod monkeypatch.setattr(mod, 'get_session', lambda: _fake_session([])) r = client.get('/observability/agent_orchestration') assert r.status_code == 200 def test_health_indicator_api_returns_json(client, monkeypatch): """/observability/api/health_indicator — Phase 52 topbar 健康指示燈 JSON API。""" from routes import admin_observability_routes as mod monkeypatch.setattr(mod, 'get_session', lambda: _fake_session([])) r = client.get('/observability/api/health_indicator') assert r.status_code == 200 assert r.content_type.startswith('application/json'), \ f'expected JSON, got {r.content_type}' # ────────────────────────────────────────────────────────────────────────── # Phase 50/40 mutation endpoints — logged-in 成功路徑驗 JSON 結構 # ────────────────────────────────────────────────────────────────────────── def test_playbook_toggle_404_on_missing(client, monkeypatch): """playbook_toggle 對不存在 id 必回 404 + ok=false。""" from routes import admin_observability_routes as mod from unittest.mock import MagicMock # 自訂 mock:fetchone 回 None 表示「playbook 不存在」 sess = MagicMock() result = MagicMock() result.fetchone.return_value = None sess.execute.return_value = result sess.close = MagicMock() monkeypatch.setattr(mod, 'get_session', lambda: sess) r = client.post('/observability/playbooks/toggle/9999') assert r.status_code == 404 body = r.get_json() assert body['ok'] is False assert '9999' in body['error'] def test_playbook_toggle_flips_active_flag(client, monkeypatch): """playbook_toggle 應翻轉 is_active 並回新狀態。""" from routes import admin_observability_routes as mod # 模擬現有 playbook (id=5, name='test', is_active=False) monkeypatch.setattr( mod, 'get_session', lambda: _fake_session([(5, 'test_playbook', False)]), ) r = client.post('/observability/playbooks/toggle/5') assert r.status_code == 200 body = r.get_json() assert body['ok'] is True assert body['is_active'] is True # 從 False 翻 True assert body['name'] == 'test_playbook' assert '啟用' in body['message'] def test_budget_force_throttle_invokes_evaluate(client, monkeypatch): """budget_force_throttle 應呼叫 cost_throttle.evaluate() 且回 ok=true。""" from routes import admin_observability_routes as mod invoked = {'count': 0} class FakeThrottle: def evaluate(self): invoked['count'] += 1 return {'gemini': {'throttled': False, 'ratio': 0.5}} # patch import 路徑(route 內 from services.cost_throttle_service import ...) import services.cost_throttle_service as cts_mod monkeypatch.setattr(cts_mod, 'cost_throttle_service', FakeThrottle(), raising=False) r = client.post('/observability/budget/force_throttle') # 路由不爆即可(auth 通 + JSON 回);status code 視 service 實作可為 # 200(正常)/ 400(驗證失敗)/ 500(service 異常) assert r.status_code in (200, 400, 500) assert r.is_json def test_ai_calls_trigger_code_review_returns_json(client, monkeypatch): """trigger_code_review 至少要回 JSON(success or service unavailable 都 ok)。""" from routes import admin_observability_routes as mod monkeypatch.setattr(mod, 'get_session', lambda: _fake_session([])) r = client.post('/observability/ai_calls/trigger_code_review') assert r.is_json assert r.status_code in (200, 500, 503) def test_host_health_trigger_autoheal_returns_json(client, monkeypatch): """trigger_autoheal 至少要回 JSON。""" from routes import admin_observability_routes as mod monkeypatch.setattr(mod, 'get_session', lambda: _fake_session([])) r = client.post( '/observability/host_health/trigger_autoheal', json={'host': 'http://192.168.0.111:11434'}, ) assert r.is_json assert r.status_code in (200, 400, 500)