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:
175
backend/tests/backend/app/services/test_log_service.py
Normal file
175
backend/tests/backend/app/services/test_log_service.py
Normal 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
|
||||
Reference in New Issue
Block a user