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 漏出。
378 lines
17 KiB
Python
378 lines
17 KiB
Python
"""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)
|