1238 lines
46 KiB
Python
1238 lines
46 KiB
Python
"""API endpoint tests"""
|
|
|
|
import pytest
|
|
from datetime import datetime
|
|
from unittest.mock import patch, AsyncMock
|
|
from httpx import AsyncClient, ASGITransport
|
|
|
|
from app.api.v1 import earth as earth_api
|
|
from app.main import app
|
|
from app.core.config import settings
|
|
from app.core.security import create_access_token
|
|
from app.db.session import get_db
|
|
from app.models.system_setting import SystemSetting
|
|
from app.models.user import User
|
|
from app.schemas.ai import (
|
|
AIProviderStatusResponse,
|
|
PlaygroundSessionResponse,
|
|
PlaygroundSessionState,
|
|
SituationalAnalysisResponse,
|
|
)
|
|
|
|
|
|
class _FakeRedisClient:
|
|
def sismember(self, *_args, **_kwargs):
|
|
return False
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def fake_token_blacklist(monkeypatch):
|
|
"""Keep API auth tests independent from an external Redis service."""
|
|
monkeypatch.setattr("app.core.security.redis_client", _FakeRedisClient())
|
|
|
|
|
|
@pytest.fixture
|
|
def auth_headers():
|
|
"""Create authentication headers"""
|
|
token = create_access_token({"sub": "1", "username": "testuser"})
|
|
return {"Authorization": f"Bearer {token}"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_health_check():
|
|
"""Test health check endpoint"""
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
response = await client.get("/health")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["status"] == "healthy"
|
|
assert "version" in data
|
|
assert response.headers["x-request-id"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_root_endpoint():
|
|
"""Test root endpoint"""
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
response = await client.get("/")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["name"] == settings.PROJECT_NAME
|
|
assert data["version"] == settings.VERSION
|
|
|
|
|
|
class _ScalarOneOrNoneResult:
|
|
def __init__(self, value=None):
|
|
self._value = value
|
|
|
|
def scalar_one_or_none(self):
|
|
return self._value
|
|
|
|
|
|
class _FakeEarthBrandSession:
|
|
def __init__(self, record=None):
|
|
self.record = record
|
|
self.added = None
|
|
self.deleted = False
|
|
self.committed = False
|
|
|
|
async def execute(self, statement):
|
|
if statement.__class__.__name__ == "Delete":
|
|
self.deleted = True
|
|
self.record = None
|
|
return _ScalarOneOrNoneResult(None)
|
|
return _ScalarOneOrNoneResult(self.record)
|
|
|
|
def add(self, record):
|
|
self.added = record
|
|
self.record = record
|
|
|
|
async def commit(self):
|
|
self.committed = True
|
|
|
|
async def refresh(self, _record):
|
|
return None
|
|
|
|
|
|
def _override_admin_user():
|
|
return User(id=1, username="testuser", email="test@example.com", role="admin", is_active=True)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_earth_brand_returns_static_defaults():
|
|
async def override_get_db():
|
|
yield _FakeEarthBrandSession()
|
|
|
|
app.dependency_overrides[get_db] = override_get_db
|
|
try:
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
response = await client.get("/api/v1/earth/brand")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["is_default"] is True
|
|
assert data["brand"]["logo_src"] == "/earth/assets/brand/earth-logo.png"
|
|
assert data["brand"]["title_src"] == "/earth/assets/brand/title-zh.png"
|
|
finally:
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_and_reset_earth_brand(auth_headers):
|
|
session = _FakeEarthBrandSession()
|
|
|
|
async def override_get_db():
|
|
yield session
|
|
|
|
app.dependency_overrides.update(
|
|
{
|
|
__import__("app.core.security", fromlist=["get_current_user"]).get_current_user: _override_admin_user,
|
|
get_db: override_get_db,
|
|
}
|
|
)
|
|
try:
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
update_response = await client.put(
|
|
"/api/v1/earth/brand",
|
|
headers=auth_headers,
|
|
json={
|
|
"logo_src": "/earth-brand-assets/custom.png",
|
|
"title_src": "",
|
|
"title_text": "Custom Earth",
|
|
"subtitle": "Custom subtitle",
|
|
"description": "Custom description",
|
|
"aria_label": "",
|
|
"title_alt": "",
|
|
},
|
|
)
|
|
reset_response = await client.delete("/api/v1/earth/brand", headers=auth_headers)
|
|
|
|
assert update_response.status_code == 200
|
|
updated = update_response.json()
|
|
assert updated["is_default"] is False
|
|
assert updated["brand"]["title_text"] == "Custom Earth"
|
|
assert updated["brand"]["aria_label"] == "Custom Earth"
|
|
assert isinstance(session.added, SystemSetting)
|
|
assert reset_response.status_code == 200
|
|
reset = reset_response.json()
|
|
assert reset["is_default"] is True
|
|
assert reset["brand"]["logo_src"] == "/earth/assets/brand/earth-logo.png"
|
|
finally:
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upload_earth_brand_asset_rejects_invalid_type(auth_headers):
|
|
app.dependency_overrides[
|
|
__import__("app.core.security", fromlist=["get_current_user"]).get_current_user
|
|
] = _override_admin_user
|
|
try:
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
response = await client.post(
|
|
"/api/v1/earth/brand/assets",
|
|
headers=auth_headers,
|
|
files={"file": ("brand.txt", b"nope", "text/plain")},
|
|
)
|
|
assert response.status_code == 400
|
|
assert response.json()["detail"]["code"] == "unsupported_file_type"
|
|
finally:
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upload_earth_brand_asset_saves_file(auth_headers, tmp_path, monkeypatch):
|
|
monkeypatch.setattr(earth_api, "EARTH_BRAND_ASSET_DIR", tmp_path)
|
|
app.dependency_overrides[
|
|
__import__("app.core.security", fromlist=["get_current_user"]).get_current_user
|
|
] = _override_admin_user
|
|
try:
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
response = await client.post(
|
|
"/api/v1/earth/brand/assets",
|
|
headers=auth_headers,
|
|
files={"file": ("brand.png", b"png-bytes", "image/png")},
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["url"].startswith("/earth-brand-assets/")
|
|
assert (tmp_path / data["filename"]).read_bytes() == b"png-bytes"
|
|
finally:
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dashboard_stats_without_auth():
|
|
"""Test dashboard stats requires authentication"""
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
response = await client.get("/api/v1/dashboard/stats")
|
|
assert response.status_code == 401
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dashboard_stats_with_auth(auth_headers):
|
|
"""Test dashboard stats with authentication"""
|
|
class _StatsResult:
|
|
def __init__(self, row):
|
|
self._row = row
|
|
|
|
def one(self):
|
|
return self._row
|
|
|
|
class _FakeStatsSession:
|
|
def __init__(self):
|
|
self._rows = [
|
|
type("DatasourceStats", (), {"custom_count": 0, "custom_active": 0})(),
|
|
type("TaskStats", (), {"tasks_today": 0, "success_tasks": 0})(),
|
|
type(
|
|
"AlertStats",
|
|
(),
|
|
{"critical_alerts": 0, "warning_alerts": 0, "info_alerts": 0},
|
|
)(),
|
|
]
|
|
|
|
async def execute(self, _query):
|
|
return _StatsResult(self._rows.pop(0))
|
|
|
|
def override_get_current_user():
|
|
return User(id=1, username="testuser", email="test@example.com", role="admin", is_active=True)
|
|
|
|
async def override_get_db():
|
|
yield _FakeStatsSession()
|
|
|
|
app.dependency_overrides.update(
|
|
{
|
|
__import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user,
|
|
get_db: override_get_db,
|
|
}
|
|
)
|
|
try:
|
|
with patch("app.api.v1.dashboard.cache.get", return_value=None):
|
|
with patch("app.api.v1.dashboard.cache.set", return_value=True):
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
response = await client.get(
|
|
"/api/v1/dashboard/stats",
|
|
headers=auth_headers,
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert "total_datasources" in data
|
|
finally:
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_alerts_without_auth():
|
|
"""Test alerts endpoint requires authentication"""
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
response = await client.get("/api/v1/alerts")
|
|
assert response.status_code == 401
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_datasource_task_status_without_auth():
|
|
"""Test datasource task-status endpoint requires authentication"""
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
response = await client.get("/api/v1/datasources/1/task-status")
|
|
assert response.status_code == 401
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_alerts_endpoint_with_auth(auth_headers):
|
|
"""Test alerts endpoint with authentication"""
|
|
class _ScalarResult:
|
|
def __init__(self, rows=None, scalar_value=0):
|
|
self._rows = rows or []
|
|
self._scalar_value = scalar_value
|
|
|
|
def scalars(self):
|
|
class _Scalars:
|
|
def __init__(self, rows):
|
|
self._rows = rows
|
|
|
|
def all(self):
|
|
return self._rows
|
|
|
|
return _Scalars(self._rows)
|
|
|
|
def scalar(self):
|
|
return self._scalar_value
|
|
|
|
class _FakeAlertsSession:
|
|
def __init__(self):
|
|
self.calls = 0
|
|
|
|
async def execute(self, _query):
|
|
self.calls += 1
|
|
if self.calls == 1:
|
|
return _ScalarResult(rows=[])
|
|
return _ScalarResult(rows=[], scalar_value=0)
|
|
|
|
def override_get_current_user():
|
|
return User(
|
|
id=1,
|
|
username="testuser",
|
|
email="test@example.com",
|
|
password_hash="hashed",
|
|
role="admin",
|
|
is_active=True,
|
|
)
|
|
|
|
async def override_get_db():
|
|
yield _FakeAlertsSession()
|
|
|
|
app.dependency_overrides = {
|
|
__import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user,
|
|
get_db: override_get_db,
|
|
}
|
|
transport = ASGITransport(app=app)
|
|
try:
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
response = await client.get("/api/v1/alerts", headers=auth_headers)
|
|
assert response.status_code == 200
|
|
finally:
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_system_log_sources_requires_super_admin(auth_headers):
|
|
def override_get_current_user():
|
|
return User(
|
|
id=1,
|
|
username="testuser",
|
|
email="test@example.com",
|
|
password_hash="hashed",
|
|
role="admin",
|
|
is_active=True,
|
|
)
|
|
|
|
app.dependency_overrides = {
|
|
__import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user,
|
|
}
|
|
transport = ASGITransport(app=app)
|
|
try:
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
response = await client.get("/api/v1/system/logs/sources", headers=auth_headers)
|
|
assert response.status_code == 403
|
|
finally:
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_system_log_sources_with_super_admin(auth_headers):
|
|
def override_get_current_user():
|
|
return User(
|
|
id=1,
|
|
username="root",
|
|
email="root@example.com",
|
|
password_hash="hashed",
|
|
role="super_admin",
|
|
is_active=True,
|
|
)
|
|
|
|
app.dependency_overrides = {
|
|
__import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user,
|
|
}
|
|
transport = ASGITransport(app=app)
|
|
try:
|
|
with patch(
|
|
"app.api.v1.system_control.list_log_sources",
|
|
return_value=[
|
|
{
|
|
"source_id": "backend",
|
|
"name": "后端服务",
|
|
"kind": "file",
|
|
"location": "/tmp/planet_backend.log",
|
|
"description": "FastAPI 后端、调度器和采集任务共享日志。",
|
|
"category": "service",
|
|
"status": "ok",
|
|
}
|
|
],
|
|
):
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
response = await client.get("/api/v1/system/logs/sources", headers=auth_headers)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["items"][0]["source_id"] == "backend"
|
|
finally:
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_system_log_snapshot_with_super_admin(auth_headers):
|
|
def override_get_current_user():
|
|
return User(
|
|
id=1,
|
|
username="root",
|
|
email="root@example.com",
|
|
password_hash="hashed",
|
|
role="super_admin",
|
|
is_active=True,
|
|
)
|
|
|
|
app.dependency_overrides = {
|
|
__import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user,
|
|
}
|
|
transport = ASGITransport(app=app)
|
|
try:
|
|
with patch(
|
|
"app.api.v1.system_control.read_log_snapshot",
|
|
return_value={
|
|
"source_id": "backend",
|
|
"name": "后端服务",
|
|
"kind": "file",
|
|
"location": "/tmp/planet_backend.log",
|
|
"description": "FastAPI 后端、调度器和采集任务共享日志。",
|
|
"category": "service",
|
|
"status": "ok",
|
|
"level": "all",
|
|
"selected_levels": [],
|
|
"search_query": "",
|
|
"available_levels": ["all", "error", "warning", "info", "debug"],
|
|
"daily_markers": [],
|
|
"line_limit": 50,
|
|
"line_count": 2,
|
|
"lines": ["line 1", "line 2"],
|
|
},
|
|
):
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
response = await client.get("/api/v1/system/logs/backend?limit=50", headers=auth_headers)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["source_id"] == "backend"
|
|
assert data["line_count"] == 2
|
|
assert data["lines"] == ["line 1", "line 2"]
|
|
finally:
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_system_log_snapshot_supports_level_filter(auth_headers):
|
|
def override_get_current_user():
|
|
return User(
|
|
id=1,
|
|
username="root",
|
|
email="root@example.com",
|
|
password_hash="hashed",
|
|
role="super_admin",
|
|
is_active=True,
|
|
)
|
|
|
|
app.dependency_overrides = {
|
|
__import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user,
|
|
}
|
|
transport = ASGITransport(app=app)
|
|
try:
|
|
with patch(
|
|
"app.api.v1.system_control.read_log_snapshot",
|
|
return_value={
|
|
"source_id": "backend",
|
|
"name": "后端服务",
|
|
"kind": "file",
|
|
"location": "/tmp/planet_backend.log",
|
|
"description": "FastAPI 后端、调度器和采集任务共享日志。",
|
|
"category": "service",
|
|
"status": "ok",
|
|
"level": "error",
|
|
"selected_levels": ["error"],
|
|
"search_query": "",
|
|
"available_levels": ["all", "error", "warning", "info", "debug"],
|
|
"daily_markers": [],
|
|
"line_limit": 50,
|
|
"line_count": 1,
|
|
"lines": ["ERROR: failed"],
|
|
},
|
|
):
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
response = await client.get("/api/v1/system/logs/backend?limit=50&level=error", headers=auth_headers)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["level"] == "error"
|
|
assert data["lines"] == ["ERROR: failed"]
|
|
finally:
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_system_log_snapshot_supports_date_range_filter(auth_headers):
|
|
def override_get_current_user():
|
|
return User(
|
|
id=1,
|
|
username="root",
|
|
email="root@example.com",
|
|
password_hash="hashed",
|
|
role="super_admin",
|
|
is_active=True,
|
|
)
|
|
|
|
app.dependency_overrides = {
|
|
__import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user,
|
|
}
|
|
transport = ASGITransport(app=app)
|
|
try:
|
|
with patch(
|
|
"app.api.v1.system_control.read_log_snapshot",
|
|
return_value={
|
|
"source_id": "backend",
|
|
"name": "后端服务",
|
|
"kind": "file",
|
|
"location": "/tmp/planet_backend.log",
|
|
"description": "FastAPI 后端、调度器和采集任务共享日志。",
|
|
"category": "service",
|
|
"status": "ok",
|
|
"level": "all",
|
|
"selected_levels": [],
|
|
"search_query": "",
|
|
"available_levels": ["all", "error", "warning", "info", "debug"],
|
|
"daily_markers": [],
|
|
"line_limit": 50,
|
|
"line_count": 1,
|
|
"lines": ["2026-04-23 INFO: service started"],
|
|
},
|
|
) as mock_read_log_snapshot:
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
response = await client.get(
|
|
"/api/v1/system/logs/backend?limit=50&start_date=2026-04-20&end_date=2026-04-23",
|
|
headers=auth_headers,
|
|
)
|
|
assert response.status_code == 200
|
|
mock_read_log_snapshot.assert_called_once_with(
|
|
"backend",
|
|
50,
|
|
level="all",
|
|
levels=None,
|
|
start_date="2026-04-20",
|
|
end_date="2026-04-23",
|
|
search=None,
|
|
)
|
|
finally:
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_system_log_snapshot_supports_levels_and_search_filter(auth_headers):
|
|
def override_get_current_user():
|
|
return User(
|
|
id=1,
|
|
username="root",
|
|
email="root@example.com",
|
|
password_hash="hashed",
|
|
role="super_admin",
|
|
is_active=True,
|
|
)
|
|
|
|
app.dependency_overrides = {
|
|
__import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user,
|
|
}
|
|
transport = ASGITransport(app=app)
|
|
try:
|
|
with patch(
|
|
"app.api.v1.system_control.read_log_snapshot",
|
|
return_value={
|
|
"source_id": "backend",
|
|
"name": "后端服务",
|
|
"kind": "file",
|
|
"location": "/tmp/planet_backend.log",
|
|
"description": "FastAPI 后端、调度器和采集任务共享日志。",
|
|
"category": "service",
|
|
"status": "ok",
|
|
"level": "all",
|
|
"selected_levels": ["error", "warning"],
|
|
"search_query": "timeout",
|
|
"available_levels": ["all", "error", "warning", "info", "debug"],
|
|
"daily_markers": [],
|
|
"line_limit": 50,
|
|
"line_count": 1,
|
|
"lines": ["2026-04-23 10:00:00 ERROR timeout"],
|
|
},
|
|
) as mock_read_log_snapshot:
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
response = await client.get(
|
|
"/api/v1/system/logs/backend?limit=50&levels=error,warning&search=timeout",
|
|
headers=auth_headers,
|
|
)
|
|
assert response.status_code == 200
|
|
mock_read_log_snapshot.assert_called_once_with(
|
|
"backend",
|
|
50,
|
|
level="all",
|
|
levels="error,warning",
|
|
start_date=None,
|
|
end_date=None,
|
|
search="timeout",
|
|
)
|
|
finally:
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_system_log_snapshot_rejects_invalid_date_range(auth_headers):
|
|
def override_get_current_user():
|
|
return User(
|
|
id=1,
|
|
username="root",
|
|
email="root@example.com",
|
|
password_hash="hashed",
|
|
role="super_admin",
|
|
is_active=True,
|
|
)
|
|
|
|
app.dependency_overrides = {
|
|
__import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user,
|
|
}
|
|
transport = ASGITransport(app=app)
|
|
try:
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
response = await client.get(
|
|
"/api/v1/system/logs/backend?start_date=2026-04-31",
|
|
headers=auth_headers,
|
|
)
|
|
assert response.status_code == 400
|
|
assert "start_date must be in YYYY-MM-DD format" in response.json()["detail"]
|
|
finally:
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ingest_earth_client_log_accepts_public_events():
|
|
transport = ASGITransport(app=app)
|
|
try:
|
|
with patch("app.api.v1.system_control.append_buffer_log") as mock_append_buffer_log:
|
|
with patch("app.api.v1.system_control.record_system_log", new_callable=AsyncMock) as mock_record_system_log:
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
response = await client.post(
|
|
"/api/v1/system/logs/earth-client",
|
|
json={
|
|
"level": "error",
|
|
"message": "登陆点加载失败: 登陆点接口返回 HTTP 500",
|
|
"category": "startup-load",
|
|
"module": "layer-startup",
|
|
"fingerprint": "client-test",
|
|
"occurrence_count": 3,
|
|
},
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["accepted"] is True
|
|
assert data["source_id"] == "earth-client"
|
|
mock_append_buffer_log.assert_called_once()
|
|
mock_record_system_log.assert_awaited_once()
|
|
persisted_kwargs = mock_record_system_log.await_args.kwargs
|
|
assert persisted_kwargs["source"] == "earth-client"
|
|
assert persisted_kwargs["event"] == "earth.client.runtime_log"
|
|
assert persisted_kwargs["category"] == "startup-load"
|
|
assert persisted_kwargs["level"] == "error"
|
|
assert persisted_kwargs["fingerprint"] == "client-test"
|
|
assert persisted_kwargs["occurrence_count"] == 3
|
|
finally:
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ingest_admin_client_log_accepts_public_events():
|
|
transport = ASGITransport(app=app)
|
|
try:
|
|
with patch("app.api.v1.system_control.append_buffer_log") as mock_append_buffer_log:
|
|
with patch("app.api.v1.system_control.record_system_log", new_callable=AsyncMock) as mock_record_system_log:
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
response = await client.post(
|
|
"/api/v1/system/logs/admin-client",
|
|
json={
|
|
"level": "error",
|
|
"message": "控制台发生未处理 Promise 错误",
|
|
"category": "unhandledrejection",
|
|
"module": "admin",
|
|
"url": "http://test/logs",
|
|
"detail": "stack preview",
|
|
},
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["accepted"] is True
|
|
assert data["source_id"] == "admin-client"
|
|
mock_append_buffer_log.assert_called_once()
|
|
mock_record_system_log.assert_awaited_once()
|
|
persisted_kwargs = mock_record_system_log.await_args.kwargs
|
|
assert persisted_kwargs["source"] == "admin-client"
|
|
assert persisted_kwargs["event"] == "admin.client.runtime_log"
|
|
assert persisted_kwargs["category"] == "unhandledrejection"
|
|
assert persisted_kwargs["context"]["url"] == "http://test/logs"
|
|
finally:
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ingest_service_log_requires_configured_token(monkeypatch):
|
|
monkeypatch.setattr(settings, "OBSERVABILITY_INGEST_TOKEN", "")
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
response = await client.post(
|
|
"/api/v1/system/logs/service",
|
|
json={"message": "AI provider failed"},
|
|
headers={"X-Planet-Observability-Token": "secret"},
|
|
)
|
|
|
|
assert response.status_code == 503
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ingest_service_log_accepts_internal_token(monkeypatch):
|
|
monkeypatch.setattr(settings, "OBSERVABILITY_INGEST_TOKEN", "service-secret")
|
|
transport = ASGITransport(app=app)
|
|
with patch("app.api.v1.system_control.record_system_log", new_callable=AsyncMock) as mock_record_system_log:
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
response = await client.post(
|
|
"/api/v1/system/logs/service",
|
|
json={
|
|
"source": "ai-provider",
|
|
"service": "ai-provider",
|
|
"module": "provider",
|
|
"category": "connectivity",
|
|
"event": "ai.provider.test.failed",
|
|
"level": "error",
|
|
"message": "Provider connectivity failed",
|
|
"fingerprint": "ai-provider-test",
|
|
"occurrence_count": 4,
|
|
"provider": "minimax",
|
|
"trace_id": "trace-123",
|
|
"context": {"status_code": 502},
|
|
},
|
|
headers={"Authorization": "Bearer service-secret"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["accepted"] is True
|
|
assert data["source_id"] == "ai-provider"
|
|
mock_record_system_log.assert_awaited_once()
|
|
persisted_kwargs = mock_record_system_log.await_args.kwargs
|
|
assert persisted_kwargs["event"] == "ai.provider.test.failed"
|
|
assert persisted_kwargs["fingerprint"] == "ai-provider-test"
|
|
assert persisted_kwargs["occurrence_count"] == 4
|
|
assert persisted_kwargs["context"]["provider"] == "minimax"
|
|
assert persisted_kwargs["context"]["trace_id"] == "trace-123"
|
|
assert persisted_kwargs["context"]["status_code"] == 502
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_earth_layer_cache_status_requires_super_admin(auth_headers, monkeypatch):
|
|
def override_get_current_user():
|
|
return User(
|
|
id=1,
|
|
username="root",
|
|
email="root@example.com",
|
|
password_hash="hashed",
|
|
role="super_admin",
|
|
is_active=True,
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
"app.api.v1.system_control.earth_layer_cache.status",
|
|
lambda: {
|
|
"prefix": "earth:layer:v1",
|
|
"key_count": 2,
|
|
"memory_bytes": 42,
|
|
"layers": {"cables": {"keys": 2, "stale_keys": 1, "memory_bytes": 42}},
|
|
},
|
|
)
|
|
|
|
app.dependency_overrides = {
|
|
__import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user,
|
|
}
|
|
transport = ASGITransport(app=app)
|
|
try:
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
response = await client.get("/api/v1/system/cache/earth-layers", headers=auth_headers)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["prefix"] == "earth:layer:v1"
|
|
assert data["layers"]["cables"]["stale_keys"] == 1
|
|
finally:
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_clear_earth_layer_cache_deletes_only_earth_layer_prefix(auth_headers, monkeypatch):
|
|
def override_get_current_user():
|
|
return User(
|
|
id=1,
|
|
username="root",
|
|
email="root@example.com",
|
|
password_hash="hashed",
|
|
role="super_admin",
|
|
is_active=True,
|
|
)
|
|
|
|
captured = {}
|
|
|
|
def fake_delete_pattern(pattern="earth:layer:v1:*"):
|
|
captured["pattern"] = pattern
|
|
return 3
|
|
|
|
monkeypatch.setattr("app.api.v1.system_control.earth_layer_cache.delete_pattern", fake_delete_pattern)
|
|
|
|
app.dependency_overrides = {
|
|
__import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user,
|
|
}
|
|
transport = ASGITransport(app=app)
|
|
try:
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
response = await client.delete("/api/v1/system/cache/earth-layers", headers=auth_headers)
|
|
assert response.status_code == 200
|
|
assert response.json()["deleted"] == 3
|
|
assert captured["pattern"] == "earth:layer:v1:*"
|
|
finally:
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_request_id_header_is_echoed_when_provided():
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
response = await client.get("/health", headers={"X-Request-ID": "planet-test-request"})
|
|
assert response.status_code == 200
|
|
assert response.headers["x-request-id"] == "planet-test-request"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_invalid_token():
|
|
"""Test that invalid token is rejected"""
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
response = await client.get(
|
|
"/api/v1/dashboard/stats",
|
|
headers={"Authorization": "Bearer invalid_token"},
|
|
)
|
|
assert response.status_code == 401
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ai_provider_status_with_auth(auth_headers):
|
|
"""Test AI provider status endpoint"""
|
|
class _FakeAIProviderClient:
|
|
async def get_status(self, request_id=None):
|
|
return AIProviderStatusResponse(
|
|
provider="minimax",
|
|
api="anthropic-messages",
|
|
enabled=True,
|
|
configured=True,
|
|
model="test-model",
|
|
base_url="http://aiprovider:8010",
|
|
)
|
|
|
|
def override_get_current_user():
|
|
return User(
|
|
id=1,
|
|
username="testuser",
|
|
email="test@example.com",
|
|
password_hash="hashed",
|
|
role="admin",
|
|
is_active=True,
|
|
)
|
|
|
|
app.dependency_overrides = {
|
|
__import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user,
|
|
__import__("app.services.ai_client", fromlist=["get_ai_provider_client"]).get_ai_provider_client: lambda: _FakeAIProviderClient(),
|
|
}
|
|
transport = ASGITransport(app=app)
|
|
try:
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
response = await client.get("/api/v1/ai/provider/status", headers=auth_headers)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert "provider" in data
|
|
assert "api" in data
|
|
assert "configured" in data
|
|
finally:
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ai_situational_analysis_returns_503_when_disabled(auth_headers):
|
|
"""Test AI analysis endpoint proxies provider service response"""
|
|
class _FakeAIProviderClient:
|
|
async def analyze(self, _payload, request_id=None):
|
|
return SituationalAnalysisResponse(
|
|
provider="openai_compatible",
|
|
model="test-model",
|
|
content="1) 态势摘要: 测试返回",
|
|
content_blocks=[],
|
|
text_blocks=["1) 态势摘要: 测试返回"],
|
|
thinking_blocks=[],
|
|
raw_response={"id": "mock-response"},
|
|
)
|
|
|
|
def override_get_current_user():
|
|
return User(
|
|
id=1,
|
|
username="testuser",
|
|
email="test@example.com",
|
|
password_hash="hashed",
|
|
role="admin",
|
|
is_active=True,
|
|
)
|
|
|
|
app.dependency_overrides = {
|
|
__import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user,
|
|
__import__("app.services.ai_client", fromlist=["get_ai_provider_client"]).get_ai_provider_client: lambda: _FakeAIProviderClient(),
|
|
}
|
|
transport = ASGITransport(app=app)
|
|
try:
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
response = await client.post(
|
|
"/api/v1/ai/situational-awareness/analyze",
|
|
headers=auth_headers,
|
|
json={
|
|
"title": "BGP 异常研判",
|
|
"objective": "给出当前异常的风险摘要和建议动作",
|
|
"observations": ["collector A 在 5 分钟内出现多个 origin 变更"],
|
|
"constraints": ["不要假设缺失数据"],
|
|
},
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["provider"] == "openai_compatible"
|
|
assert data["content"]
|
|
assert "content_blocks" in data
|
|
assert "text_blocks" in data
|
|
assert "thinking_blocks" in data
|
|
finally:
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_playground_session_with_auth(auth_headers):
|
|
"""Test playground session restore endpoint."""
|
|
|
|
def override_get_current_user():
|
|
return User(
|
|
id=1,
|
|
username="testuser",
|
|
email="test@example.com",
|
|
password_hash="hashed",
|
|
role="admin",
|
|
is_active=True,
|
|
)
|
|
|
|
async def override_get_db():
|
|
yield AsyncMock()
|
|
|
|
app.dependency_overrides = {
|
|
__import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user,
|
|
get_db: override_get_db,
|
|
}
|
|
transport = ASGITransport(app=app)
|
|
try:
|
|
with patch(
|
|
"app.api.v1.ai.get_playground_session",
|
|
new=AsyncMock(
|
|
return_value=PlaygroundSessionResponse(
|
|
id="1",
|
|
session_key="default",
|
|
title="Playground 会话",
|
|
state=PlaygroundSessionState(
|
|
messages=[{"id": "msg-1", "role": "user", "content": "hello"}],
|
|
title="测试标题",
|
|
objective="测试目标",
|
|
),
|
|
created_at="2026-04-10T00:00:00+00:00",
|
|
updated_at="2026-04-10T00:00:00+00:00",
|
|
)
|
|
),
|
|
):
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
response = await client.get("/api/v1/ai/playground/session", headers=auth_headers)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["session_key"] == "default"
|
|
assert data["state"]["messages"][0]["content"] == "hello"
|
|
finally:
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_save_playground_session_with_auth(auth_headers):
|
|
"""Test playground session save endpoint."""
|
|
|
|
def override_get_current_user():
|
|
return User(
|
|
id=1,
|
|
username="testuser",
|
|
email="test@example.com",
|
|
password_hash="hashed",
|
|
role="admin",
|
|
is_active=True,
|
|
)
|
|
|
|
async def override_get_db():
|
|
yield AsyncMock()
|
|
|
|
app.dependency_overrides = {
|
|
__import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user,
|
|
get_db: override_get_db,
|
|
}
|
|
transport = ASGITransport(app=app)
|
|
try:
|
|
with patch(
|
|
"app.api.v1.ai.upsert_playground_session",
|
|
new=AsyncMock(
|
|
return_value=PlaygroundSessionResponse(
|
|
id="1",
|
|
session_key="default",
|
|
title="测试标题",
|
|
state=PlaygroundSessionState(
|
|
messages=[{"id": "msg-1", "role": "user", "content": "hello"}],
|
|
title="测试标题",
|
|
objective="测试目标",
|
|
),
|
|
created_at="2026-04-10T00:00:00+00:00",
|
|
updated_at="2026-04-10T00:00:00+00:00",
|
|
)
|
|
),
|
|
):
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
response = await client.put(
|
|
"/api/v1/ai/playground/session",
|
|
headers=auth_headers,
|
|
json={
|
|
"session_key": "default",
|
|
"title": "测试标题",
|
|
"state": {
|
|
"messages": [{"id": "msg-1", "role": "user", "content": "hello"}],
|
|
"selectedPresetKey": "bgp-brief",
|
|
"title": "测试标题",
|
|
"objective": "测试目标",
|
|
"constraints": "",
|
|
"inputValue": "",
|
|
"analysis": None,
|
|
"latestAnalysisMessageId": None,
|
|
"analysisMeta": {},
|
|
"helpExpanded": True,
|
|
},
|
|
},
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["title"] == "测试标题"
|
|
assert data["state"]["objective"] == "测试目标"
|
|
finally:
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ai_bgp_brief_endpoint_persists_fact_snapshot(auth_headers):
|
|
class _FakeAIProviderClient:
|
|
async def analyze(self, _payload, request_id=None):
|
|
return SituationalAnalysisResponse(
|
|
provider="minimax",
|
|
model="MiniMax-M2.5",
|
|
content="# BGP AI 简报\n\n事实摘要:测试",
|
|
content_blocks=[],
|
|
text_blocks=["# BGP AI 简报\n\n事实摘要:测试"],
|
|
thinking_blocks=[],
|
|
raw_response={"id": "mock-bgp-brief"},
|
|
)
|
|
|
|
def override_get_current_user():
|
|
return User(
|
|
id=1,
|
|
username="testuser",
|
|
email="test@example.com",
|
|
password_hash="hashed",
|
|
role="admin",
|
|
is_active=True,
|
|
)
|
|
|
|
async def override_get_db():
|
|
yield AsyncMock()
|
|
|
|
async def _fake_build_bgp_brief_request(_db, **_kwargs):
|
|
request_payload = __import__("app.schemas.ai", fromlist=["SituationalAnalysisRequest"]).SituationalAnalysisRequest(
|
|
title="BGP 态势 AI 简报",
|
|
objective="生成值班简报",
|
|
observations=["事实A", "事实B"],
|
|
constraints=["不要编造"],
|
|
context={"incident_total": 2, "active_collectors": 3},
|
|
)
|
|
return request_payload, ["事实A", "事实B"], {"incident_total": 2, "active_collectors": 3}
|
|
|
|
app.dependency_overrides = {
|
|
__import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user,
|
|
__import__("app.services.ai_client", fromlist=["get_ai_provider_client"]).get_ai_provider_client: lambda: _FakeAIProviderClient(),
|
|
get_db: override_get_db,
|
|
}
|
|
transport = ASGITransport(app=app)
|
|
try:
|
|
with patch("app.api.v1.ai.build_bgp_brief_request", side_effect=_fake_build_bgp_brief_request):
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
response = await client.post("/api/v1/ai/bgp/brief", headers=auth_headers, json={})
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["facts"] == ["事实A", "事实B"]
|
|
assert data["context"]["incident_total"] == 2
|
|
assert data["content_markdown"]
|
|
finally:
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ai_alert_brief_endpoint_with_auth(auth_headers):
|
|
class _FakeAIProviderClient:
|
|
async def analyze(self, _payload, request_id=None):
|
|
return SituationalAnalysisResponse(
|
|
provider="minimax",
|
|
model="MiniMax-M2.7",
|
|
content="事实摘要:告警测试。风险研判:告警测试。建议动作:告警测试。",
|
|
content_blocks=[],
|
|
text_blocks=["事实摘要:告警测试。风险研判:告警测试。建议动作:告警测试。"],
|
|
thinking_blocks=[],
|
|
raw_response={"id": "mock-alert-brief"},
|
|
)
|
|
|
|
def override_get_current_user():
|
|
return User(
|
|
id=1,
|
|
username="testuser",
|
|
email="test@example.com",
|
|
password_hash="hashed",
|
|
role="admin",
|
|
is_active=True,
|
|
)
|
|
|
|
async def override_get_db():
|
|
yield AsyncMock()
|
|
|
|
async def _fake_build_alert_brief_request(_db, **_kwargs):
|
|
request_payload = __import__("app.schemas.ai", fromlist=["SituationalAnalysisRequest"]).SituationalAnalysisRequest(
|
|
title="告警态势 AI 简报",
|
|
objective="输出告警简报",
|
|
observations=["告警事实A", "告警事实B"],
|
|
constraints=["不要编造"],
|
|
context={"active_alerts": 3, "top_datasources": {"bgp": 2}},
|
|
)
|
|
return request_payload, ["告警事实A", "告警事实B"], {"active_alerts": 3, "top_datasources": {"bgp": 2}}
|
|
|
|
app.dependency_overrides = {
|
|
__import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user,
|
|
__import__("app.services.ai_client", fromlist=["get_ai_provider_client"]).get_ai_provider_client: lambda: _FakeAIProviderClient(),
|
|
get_db: override_get_db,
|
|
}
|
|
transport = ASGITransport(app=app)
|
|
try:
|
|
with patch("app.api.v1.ai.build_alert_brief_request", side_effect=_fake_build_alert_brief_request):
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
response = await client.post("/api/v1/ai/alerts/brief", headers=auth_headers, json={})
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["title"] == "告警态势 AI 简报"
|
|
assert data["facts"] == ["告警事实A", "告警事实B"]
|
|
assert data["context"]["active_alerts"] == 3
|
|
assert data["content"]
|
|
finally:
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ai_situational_alert_brief_endpoint_with_auth(auth_headers):
|
|
class _FakeAIProviderClient:
|
|
async def analyze(self, _payload, request_id=None):
|
|
return SituationalAnalysisResponse(
|
|
provider="minimax",
|
|
model="MiniMax-M2.7",
|
|
content="事实摘要:态势测试。风险研判:态势测试。建议动作:态势测试。",
|
|
content_blocks=[],
|
|
text_blocks=["事实摘要:态势测试。风险研判:态势测试。建议动作:态势测试。"],
|
|
thinking_blocks=[],
|
|
raw_response={"id": "mock-situational-brief"},
|
|
)
|
|
|
|
def override_get_current_user():
|
|
return User(
|
|
id=1,
|
|
username="testuser",
|
|
email="test@example.com",
|
|
password_hash="hashed",
|
|
role="admin",
|
|
is_active=True,
|
|
)
|
|
|
|
async def override_get_db():
|
|
yield AsyncMock()
|
|
|
|
async def _fake_build_situational_alert_brief_request(_db):
|
|
request_payload = __import__("app.schemas.ai", fromlist=["SituationalAnalysisRequest"]).SituationalAnalysisRequest(
|
|
title="态势告警 AI 简报",
|
|
objective="输出态势告警简报",
|
|
observations=["态势事实A", "态势事实B"],
|
|
constraints=["不要编造"],
|
|
context={"active_system_alerts": 2, "active_bgp_incidents": 1},
|
|
)
|
|
return request_payload, ["态势事实A", "态势事实B"], {"active_system_alerts": 2, "active_bgp_incidents": 1}
|
|
|
|
app.dependency_overrides = {
|
|
__import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user,
|
|
__import__("app.services.ai_client", fromlist=["get_ai_provider_client"]).get_ai_provider_client: lambda: _FakeAIProviderClient(),
|
|
get_db: override_get_db,
|
|
}
|
|
transport = ASGITransport(app=app)
|
|
try:
|
|
with patch("app.api.v1.ai.build_situational_alert_brief_request", side_effect=_fake_build_situational_alert_brief_request):
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
response = await client.post("/api/v1/ai/situational-alerts/brief", headers=auth_headers, json={})
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["title"] == "态势告警 AI 简报"
|
|
assert data["facts"] == ["态势事实A", "态势事实B"]
|
|
assert data["context"]["active_system_alerts"] == 2
|
|
assert data["content"]
|
|
finally:
|
|
app.dependency_overrides.clear()
|