feat(logs): unify filtering across list and stats

Make runtime log queries support request correlation and date-range diagnostics with shared filtering semantics so the log page can use one consistent contract.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-21 22:11:41 +08:00
parent 204cb223a3
commit a27736a832
4 changed files with 446 additions and 132 deletions

View File

@@ -0,0 +1,175 @@
import json
from datetime import datetime, timedelta
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
import app.models # noqa: F401
from app.database import Base
from app.main import app
from app.models.log import Log
from app.models.user import User
from app.routers.auth import get_current_user
from app.database import get_db
from app.services.auth_service import get_password_hash
@pytest.fixture
async def log_test_env(tmp_path):
db_path = tmp_path / 'test_logs.db'
engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}", future=True)
session_factory = async_sessionmaker(engine, expire_on_commit=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with session_factory() as session:
current_user = User(
email='tester@example.com',
hashed_password=get_password_hash('secret123'),
full_name='Tester',
)
other_user = User(
email='other@example.com',
hashed_password=get_password_hash('secret123'),
full_name='Other',
)
session.add_all([current_user, other_user])
await session.flush()
now = datetime.utcnow()
session.add_all(
[
Log(
level='error',
type='system',
user_id=current_user.id,
request_id='req-target',
route='/api/settings',
method='PUT',
status_code=500,
operation='settings.save',
message='target error',
source='settings',
details=json.dumps({'scope': 'target'}),
created_at=now - timedelta(minutes=30),
updated_at=now - timedelta(minutes=30),
),
Log(
level='info',
type='system',
user_id=None,
request_id='req-global',
route='/api/health',
method='GET',
status_code=200,
operation='health.check',
message='global info',
source='http',
details=json.dumps({'scope': 'global'}),
created_at=now - timedelta(minutes=20),
updated_at=now - timedelta(minutes=20),
),
Log(
level='error',
type='system',
user_id=other_user.id,
request_id='req-other',
route='/api/secret',
method='GET',
status_code=500,
operation='secret.fail',
message='other user error',
source='secret',
details=json.dumps({'scope': 'other'}),
created_at=now - timedelta(minutes=10),
updated_at=now - timedelta(minutes=10),
),
Log(
level='warning',
type='chat',
user_id=current_user.id,
request_id='req-old',
route='/api/chat',
method='POST',
status_code=429,
operation='chat.send',
message='old warning',
source='chat',
details=json.dumps({'scope': 'old'}),
created_at=now - timedelta(days=10),
updated_at=now - timedelta(days=10),
),
]
)
await session.commit()
await session.refresh(current_user)
async def override_get_db():
async with session_factory() as session:
yield session
async def override_get_current_user():
return current_user
app.dependency_overrides[get_db] = override_get_db
app.dependency_overrides[get_current_user] = override_get_current_user
try:
yield
finally:
app.dependency_overrides.clear()
await engine.dispose()
@pytest.mark.asyncio
async def test_logs_list_filters_by_route_and_hides_other_user_records(log_test_env):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
response = await client.get('/api/logs', params={'route': '/api/settings'})
assert response.status_code == 200
payload = response.json()
assert payload['total'] == 1
assert [log['request_id'] for log in payload['logs']] == ['req-target']
@pytest.mark.asyncio
async def test_logs_stats_uses_same_filters_as_list(log_test_env):
transport = ASGITransport(app=app)
params = {
'route': '/api/settings',
'status_code': 500,
'level': 'error',
}
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
list_response = await client.get('/api/logs', params=params)
stats_response = await client.get('/api/logs/stats', params=params)
assert list_response.status_code == 200
assert stats_response.status_code == 200
list_payload = list_response.json()
stats_payload = stats_response.json()
assert list_payload['total'] == 1
assert stats_payload['total'] == 1
assert stats_payload['by_type']['system'] == 1
assert stats_payload['by_level']['error'] == 1
@pytest.mark.asyncio
async def test_logs_rejects_invalid_datetime_range(log_test_env):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
response = await client.get(
'/api/logs',
params={
'start_at': '2026-03-21T12:00:00+00:00',
'end_at': '2026-03-20T12:00:00+00:00',
},
)
assert response.status_code == 422