release: bump version to 0.38.0

This commit is contained in:
linkong
2026-04-23 17:57:35 +08:00
parent 195a8bf71c
commit d5f3784ffb
39 changed files with 4958 additions and 146 deletions

View File

@@ -35,6 +35,7 @@ async def test_health_check():
data = response.json()
assert data["status"] == "healthy"
assert "version" in data
assert response.headers["x-request-id"]
@pytest.mark.asyncio
@@ -161,6 +162,345 @@ async def test_alerts_endpoint_with_auth(auth_headers):
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",
},
)
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"
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"""
@@ -263,6 +603,8 @@ async def test_ai_situational_analysis_returns_503_when_disabled(auth_headers):
assert "content_blocks" in data
assert "text_blocks" in data
assert "thinking_blocks" in data
finally:
app.dependency_overrides.clear()
@pytest.mark.asyncio
@@ -382,8 +724,6 @@ async def test_save_playground_session_with_auth(auth_headers):
assert data["state"]["objective"] == "测试目标"
finally:
app.dependency_overrides.clear()
finally:
app.dependency_overrides.clear()
@pytest.mark.asyncio

View File

@@ -0,0 +1,49 @@
from datetime import UTC, datetime
from app.services.earth_news import ParsedNewsItem, _serialize_item
def test_serialize_item_includes_region_anchor_for_cruise():
item = ParsedNewsItem(
id="google-apac:test",
title="Example APAC story",
summary="Example summary",
url="https://example.com/story",
source="Example Source",
feed_name="Global Monitor / APAC",
feed_region="asia-pacific",
homepage_url="https://example.com",
published_at=datetime(2026, 4, 23, 2, 30, tzinfo=UTC),
)
payload = _serialize_item(item, active_region="asia-pacific")
assert payload["latitude"] == 1.3521
assert payload["longitude"] == 103.8198
assert payload["location_label"] == "亚太"
assert payload["location_inferred"] is True
assert payload["is_focus_match"] is True
assert payload["published_at"] == "2026-04-23T02:30:00Z"
def test_serialize_item_falls_back_to_global_anchor():
item = ParsedNewsItem(
id="custom:test",
title="Fallback story",
summary="Fallback summary",
url="https://example.com/fallback",
source="Fallback Source",
feed_name="Fallback Feed",
feed_region="unknown-region",
homepage_url="https://example.com",
published_at=None,
)
payload = _serialize_item(item, active_region="americas")
assert payload["latitude"] == 20.0
assert payload["longitude"] == 0.0
assert payload["location_label"] == "全球"
assert payload["location_inferred"] is True
assert payload["is_focus_match"] is False
assert payload["published_at"] is None

View File

@@ -0,0 +1,164 @@
from __future__ import annotations
import json
from pathlib import Path
from app.services import system_logs
class FakeRedis:
def __init__(self) -> None:
self.store: dict[str, list[str]] = {}
def rpush(self, key: str, value: str) -> None:
self.store.setdefault(key, []).append(value)
def ltrim(self, key: str, start: int, end: int) -> None:
items = self.store.get(key, [])
normalized_end = None if end == -1 else end + 1
self.store[key] = items[start:normalized_end]
def expire(self, key: str, seconds: int) -> None:
return None
def lrange(self, key: str, start: int, end: int) -> list[str]:
items = self.store.get(key, [])
normalized_end = None if end == -1 else end + 1
return items[start:normalized_end]
def llen(self, key: str) -> int:
return len(self.store.get(key, []))
def test_read_log_snapshot_uses_structured_buffer_timestamp_level_and_search(monkeypatch):
fake_redis = FakeRedis()
monkeypatch.setattr(system_logs, "redis_client", fake_redis)
monkeypatch.setattr(
system_logs,
"LOG_SOURCES",
{
"earth-client": system_logs.LogSource(
source_id="earth-client",
name="Earth 浏览器端",
kind="buffer",
location="redis://planet:system_logs:earth-client",
description="Earth 浏览器端上报日志",
category="client",
buffer_key=system_logs.get_buffer_log_key("earth-client"),
)
},
)
fake_redis.rpush(
system_logs.get_buffer_log_key("earth-client"),
json.dumps(
{
"timestamp": "2026-04-22T10:15:30Z",
"level": "warning",
"message": "news feed degraded",
"context": {"module": "news", "detail": "timeout"},
},
ensure_ascii=False,
),
)
fake_redis.rpush(
system_logs.get_buffer_log_key("earth-client"),
json.dumps(
{
"timestamp": "2026-04-23T06:01:00Z",
"level": "error",
"message": "landing points failed",
"context": {"module": "layer-startup", "detail": "http 500"},
},
ensure_ascii=False,
),
)
snapshot = system_logs.read_log_snapshot(
"earth-client",
50,
levels="error,warning",
start_date="2026-04-23",
end_date="2026-04-23",
search="landing",
)
assert snapshot is not None
assert snapshot["selected_levels"] == ["error", "warning"]
assert snapshot["search_query"] == "landing"
assert snapshot["line_count"] == 1
assert snapshot["lines"][0].startswith("2026-04-23 06:01:00 ERROR landing points failed")
assert snapshot["daily_markers"] == [
{"date_token": "2026-04-23", "total": 1, "dominant_level": "error"}
]
def test_read_log_snapshot_parses_file_timestamp_and_builds_markers(tmp_path: Path, monkeypatch):
log_path = tmp_path / "backend.log"
log_path.write_text(
"\n".join(
[
"2026-04-22 08:00:00 INFO service booted",
"2026-04-23 09:15:00 WARNING disk pressure detected",
"2026-04-23 09:16:00 ERROR sync failed",
"2026-04-24 10:00:00 DEBUG collector trace",
]
),
encoding="utf-8",
)
monkeypatch.setattr(
system_logs,
"LOG_SOURCES",
{
"backend": system_logs.LogSource(
source_id="backend",
name="后端服务",
kind="file",
location=str(log_path),
description="测试文件日志",
category="service",
)
},
)
snapshot = system_logs.read_log_snapshot(
"backend",
50,
levels="warning,error",
search="failed",
)
assert snapshot is not None
assert snapshot["line_count"] == 1
assert snapshot["lines"] == ["2026-04-23 09:16:00 ERROR sync failed"]
assert snapshot["daily_markers"] == [
{"date_token": "2026-04-23", "total": 1, "dominant_level": "error"}
]
assert snapshot["status"] == "ok"
def test_append_buffer_log_persists_normalized_level(monkeypatch):
fake_redis = FakeRedis()
monkeypatch.setattr(system_logs, "redis_client", fake_redis)
system_logs.append_buffer_log(
"earth-client",
level="warn",
message="feed delayed",
context={"module": "news"},
)
stored_items = fake_redis.lrange(system_logs.get_buffer_log_key("earth-client"), 0, -1)
payload = json.loads(stored_items[0])
assert payload["level"] == "warning"
assert payload["message"] == "feed delayed"
def test_infer_log_level_prefers_leading_prefix_over_query_string():
line = 'INFO: 127.0.0.1 - "GET /api/v1/system/logs/backend?limit=200&level=error&levels=error HTTP/1.1" 200 OK'
entry = system_logs.parse_text_log_entry(line)
assert entry.level == "info"