"""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 # ────────────────────────────────────────────────────────────────────────── # /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 # ────────────────────────────────────────────────────────────────────────── # 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)