#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ tests/test_token_report_service.py LLM Token 日報服務單元測試 (Operation Ollama-First v5.0 — Phase 1 收尾) 測試紀律: - 不真連 DB:mock _exec_query 返回固定資料 - 不真連 Telegram:mock send_telegram_with_result - 不真寫 ai_insights:mock _persist_to_ai_insights - 7 個告警規則各自獨立觸發測試 - HTML escape 驗證(caller 名含 < / & 不破版) - 訊息字數 ≤ 4096 驗證 """ from __future__ import annotations import os import sys from datetime import date, datetime, timedelta, timezone from typing import Any, Dict, List import pytest sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) import services.token_report_service as svc # ───────────────────────────────────────────────────────────────────────────── # 共用 fixtures # ───────────────────────────────────────────────────────────────────────────── TARGET_DATE = date(2026, 5, 3) def _make_summary(**overrides) -> Dict[str, Any]: base = { 'total_tokens': 3_142_891, 'total_calls': 2_847, 'total_cost_usd': 0.36, 'avg_duration_ms': 1847.0, 'success_rate': 98.7, 'failed_calls': 37, 'ollama_pct': 64.3, 'prev_total_tokens': 2_905_000, 'wow_pct': 8.2, } base.update(overrides) return base def _make_by_provider(**overrides) -> List[Dict[str, Any]]: """7 個 provider 的預設配置,可用 overrides={'gemini': {'pct': 50}} 覆寫""" defaults = { 'gcp_ollama': {'tokens': 2_021_000, 'pct': 64.3, 'calls': 2103, 'cost_usd': 0.0, 'avg_duration_ms': 1200}, 'ollama_111': {'tokens': 12_000, 'pct': 0.4, 'calls': 18, 'cost_usd': 0.0, 'avg_duration_ms': 2400}, 'gemini': {'tokens': 892_000, 'pct': 28.4, 'calls': 589, 'cost_usd': 0.31, 'avg_duration_ms': 2100}, 'claude': {'tokens': 178_000, 'pct': 5.7, 'calls': 98, 'cost_usd': 0.04, 'avg_duration_ms': 3200}, 'nim': {'tokens': 28_000, 'pct': 0.9, 'calls': 24, 'cost_usd': 0.0, 'avg_duration_ms': 1800}, 'openrouter': {'tokens': 12_000, 'pct': 0.4, 'calls': 15, 'cost_usd': 0.01, 'avg_duration_ms': 2900}, 'nim_via_elephant': {'tokens': 27_000, 'pct': 0.9, 'calls': 12, 'cost_usd': 0.0, 'avg_duration_ms': 3100}, } for k, v in (overrides or {}).items(): defaults.setdefault(k, {}).update(v) return [{'provider': k, **v} for k, v in defaults.items()] def _make_top_callers() -> List[Dict[str, Any]]: return [ {'caller': 'km_embedding_worker', 'provider': 'gcp_ollama', 'model': 'bge-m3:latest', 'tokens': 892_000, 'calls': 1247, 'delta_pct': 5.0}, {'caller': 'hermes_analyst', 'provider': 'gcp_ollama', 'model': 'hermes3:latest', 'tokens': 482_000, 'calls': 72, 'delta_pct': -2.0}, {'caller': 'code_review_hermes', 'provider': 'claude', 'model': 'claude-opus-4-7', 'tokens': 158_000, 'calls': 8, 'delta_pct': 42.0}, ] def _make_trends() -> Dict[str, Any]: return { 'today_total_tokens': 3_142_000, 'today_gemini_tokens': 892_000, 'today_ollama_tokens': 2_033_000, 'today_claude_tokens': 178_000, 'today_avg_duration': 1847.0, 'today_error_rate': 1.3, 'today_gcp_hit_pct': 99.6, '7d_avg_total': 2_905_000, '7d_avg_gemini': 948_000, '7d_avg_ollama': 1_712_000, '7d_avg_claude': 165_000, '7d_avg_duration': 1920.0, '7d_error_rate': 1.8, '7d_total_tokens': 18_832_000, '7d_total_cost': 11.84, '7d_gcp_hit_pct_7d': 98.9, '7d_gcp_hit_pct': 98.9, } def _make_budgets(**overrides) -> Dict[str, Any]: base = { 'daily_spent': 0.36, 'weekly_spent': 1.92, 'monthly_spent': 5.84, 'daily_budget': 1.00, 'weekly_budget': 5.00, 'monthly_budget': 20.00, } base.update(overrides) return base def _make_cache_stats(**overrides) -> Dict[str, Any]: base = { 'claude': {'total': 98, 'hits': 62, 'pct': 63.3}, 'gemini': {'total': 0, 'hits': 0, 'pct': 0.0}, } base.update(overrides) return base # ───────────────────────────────────────────────────────────────────────────── # 1. 報表組裝測試 — generate_daily_report 路徑 # ───────────────────────────────────────────────────────────────────────────── class TestReportFormat: """測 _format_report 主要章節都出現 & 字數合理。""" def test_format_report_contains_all_six_sections(self): """6 個段落標題都應出現。""" out = svc._format_report( target_date=TARGET_DATE, summary=_make_summary(), by_provider=_make_by_provider(), top_callers=_make_top_callers(), costs=[{'provider': 'gemini', 'model': 'gemini-2.5-flash', 'cost_usd': 0.26, 'calls': 50}], trends=_make_trends(), budgets=_make_budgets(), cache_stats=_make_cache_stats(), alerts=[], insights=[{'icon': '✅', 'text': 'Ollama-First 達標'}], ) assert '【1】今日總覽' in out assert '【2】供應商分布' in out assert '【3】呼叫點 TOP' in out assert '【4】成本分析' in out assert '【5】趨勢與洞察' in out assert '【6】告警與建議' in out def test_format_report_under_telegram_limit(self): """完整報表(含 10 個 caller / 12 個成本項 / 多個告警)不應超過 4096 字元。""" big_callers = _make_top_callers() * 4 # 12 筆 big_costs = [{'provider': 'p', 'model': f'model-{i}', 'cost_usd': 0.01, 'calls': 1} for i in range(12)] big_alerts = [ {'level': 'P1', 'icon': '🔴', 'title': 'X' * 80, 'suggestion': 'Y' * 80} for _ in range(5) ] out = svc._format_report( target_date=TARGET_DATE, summary=_make_summary(), by_provider=_make_by_provider(), top_callers=big_callers[:10], costs=big_costs, trends=_make_trends(), budgets=_make_budgets(), cache_stats=_make_cache_stats(), alerts=big_alerts, insights=[], ) # send_daily_report 端會做 4000 字截斷(HTML 安全),單元測試先確認原始長度可控 assert len(out) < 6000, f"原始報表 {len(out)} 字元,可能需縮減欄位寬度" def test_format_report_html_escape_caller_name(self): """caller 名含