Files
ewoooc/tests/test_admin_observability_routes.py
OoO 326285d8b9 test(p55): 觀測台 mutation endpoint logged-in success path 補測 (23/23 PASS)
P53 之前 mutation endpoint 只測 anon block (302),logged-in 成功路徑零覆蓋:
- /playbooks/toggle/<id>: 翻 is_active 邏輯
- /budget/force_throttle: cost_throttle.evaluate() 呼叫
- /ai_calls/trigger_code_review: code_review_pipeline 觸發
- /host_health/trigger_autoheal: autoheal playbook 觸發

新增 5 cases:
- test_playbook_toggle_404_on_missing: fetchone()=None 必回 404
- test_playbook_toggle_flips_active_flag: False→True 翻轉 + 中文 message
- test_budget_force_throttle_invokes_evaluate: monkeypatch 假 throttle service
- test_ai_calls_trigger_code_review_returns_json: 至少回 JSON 不爆
- test_host_health_trigger_autoheal_returns_json: 至少回 JSON 不爆

設計重點:對未來 service 重構容忍(status code 收 200/400/500/503)
但堅持「JSON response shape」契約 — 防 HTML error page 漏出。
2026-05-05 12:17:54 +08:00

378 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Phase 31 — admin observability 6 路由 smoke tests.
目的:保 Phase 27/28/29 6 個 admin 頁不被未來修改打掛。
不接 DBOllamaMCP全 mock。每個 route 至少驗:
1. HTTP 200 回應render_template 不爆)
2. session 失敗時走 fallbackerror 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 sessionexecute().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)
# 308permanent redirect for trailing slash或 302login 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
# 自訂 mockfetchone 回 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驗證失敗/ 500service 異常)
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 至少要回 JSONsuccess 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)