285 lines
8.7 KiB
Python
285 lines
8.7 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
|
|
from app.models.system_log import SystemLog
|
|
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="智能星球浏览器端",
|
|
kind="buffer",
|
|
location="redis://planet:system_logs:earth-client",
|
|
description="智能星球浏览器端上报日志",
|
|
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_list_log_sources_includes_admin_client(monkeypatch):
|
|
fake_redis = FakeRedis()
|
|
monkeypatch.setattr(system_logs, "redis_client", fake_redis)
|
|
|
|
sources = system_logs.list_log_sources()
|
|
|
|
admin_source = next(item for item in sources if item["source_id"] == "admin-client")
|
|
assert admin_source["kind"] == "buffer"
|
|
assert admin_source["category"] == "client"
|
|
|
|
|
|
def test_read_log_events_returns_stable_cursors(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-22 08:01:00 ERROR service failed",
|
|
]
|
|
),
|
|
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",
|
|
)
|
|
},
|
|
)
|
|
|
|
events = system_logs.read_log_events("backend", 50, level="error")
|
|
|
|
assert events is not None
|
|
assert len(events) == 1
|
|
assert events[0].source_id == "backend"
|
|
assert events[0].cursor.startswith("backend:")
|
|
assert events[0].line.endswith("ERROR service failed")
|
|
|
|
|
|
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"
|
|
|
|
|
|
def test_parse_text_log_entry_does_not_promote_exception_context_to_error():
|
|
line = "websockets.exceptions.ConnectionClosedError: sent 1011 (internal error) keepalive ping timeout"
|
|
|
|
entry = system_logs.parse_text_log_entry(line)
|
|
|
|
assert entry.level is None
|
|
|
|
|
|
def test_parse_text_log_entry_still_detects_explicit_error_prefix():
|
|
line = "ERROR: [Errno 98] Address already in use"
|
|
|
|
entry = system_logs.parse_text_log_entry(line)
|
|
|
|
assert entry.level == "error"
|
|
|
|
|
|
def test_read_log_snapshot_strips_nul_bytes_from_file_lines(tmp_path: Path, monkeypatch):
|
|
log_path = tmp_path / "backend.log"
|
|
log_path.write_bytes(
|
|
(
|
|
b"INFO: service booted\n"
|
|
b"ERROR: bind failed\n"
|
|
+ b"\x00" * 32
|
|
+ b"2026-04-23 23:41:32 INFO service=backend message=request served\n"
|
|
)
|
|
)
|
|
|
|
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)
|
|
|
|
assert snapshot is not None
|
|
assert snapshot["line_count"] == 3
|
|
assert snapshot["lines"] == [
|
|
"INFO: service booted",
|
|
"ERROR: bind failed",
|
|
"2026-04-23 23:41:32 INFO service=backend message=request served",
|
|
]
|
|
|
|
|
|
def test_database_system_log_search_matches_context_key_value_aliases():
|
|
record = SystemLog(
|
|
id=2218,
|
|
occurred_at=datetime(2026, 5, 28, 9, 14, 50, tzinfo=UTC),
|
|
source="backend",
|
|
service="collector",
|
|
module="app.services.collectors.base",
|
|
event="collector.run.failed",
|
|
level="error",
|
|
message="Collector run failed",
|
|
context={"collector_name": "celestrak_tle", "datasource_id": 20, "task_id": 26906},
|
|
)
|
|
|
|
event = system_logs._database_event_from_system_record(record)
|
|
|
|
assert system_logs.event_matches_search(event, "task_id=26906")
|
|
assert system_logs.event_matches_search(event, "datasource_id=20")
|