Files
ss-tools/backend/tests/test_app_logs_endpoint.py
busya 488a8f349b test(backend): raise coverage to 95%+ statements and branches (97.8%/95.0%)
- ~60 new/extended test files across api, core, plugins, services, schemas:
  routes, superset clients, task_manager, lineage, git, translate,
  dashboard-testing, load-testing, migration, llm_analysis, scheduler, ssl
- .coveragerc: enable branch coverage; exclude src/__tests__ (test files)
  and src/scripts (CLI/ops tools) from the denominator
- bug fixes found while testing:
  * settings: PUT /settings/reports registered under duplicated prefix
  * schemas/lineage: FleetReportDTO missing run_status (route always 500)
  * dashboard_testing/baseline_inheritance: visual entry read wrong field
  * superset_client/_databases: logger extra name shadowed LogRecord attr
  * routes/datasets: _yaml_string_paths recursion without yield from
  * translate/sql_generator: restore explicit-type timestamp contract
  * baseline_catalog: remove unreachable dashboard_id fallback
- conftest fixes: pytest_plugins to rootdir conftest (pytest 9), test
  filename collision, TMPDIR-safe integration fixtures
2026-08-19 17:14:32 +03:00

407 lines
20 KiB
Python

# #region Test.AppModule.LogsEndpoint [C:3] [TYPE Module] [SEMANTICS test,app,logs,recent,websocket]
# @BRIEF Tests for app.py — get_recent_app_logs REST endpoint and app_logs_websocket live log tail.
# @RELATION BINDS_TO -> [App.AppModule]
# @TEST_EDGE: level_filter -> lines below min level are dropped
# @TEST_EDGE: task_id_filter -> lines without matching task id are dropped
# @TEST_EDGE: after_seq -> get_since snapshot used instead of recent
# @TEST_EDGE: limit_clamp -> out-of-range limits clamped to [1, 3000]
# @TEST_EDGE: ws_auth_missing_token -> 4001 close
# @TEST_EDGE: ws_permission_denied -> 4003 close
# @TEST_EDGE: ws_poll_failure -> stream logs then re-raises
# @TEST_INVARIANT: ws_auth_gate -> VERIFIED_BY: test_app_logs_ws_auth_rejected, test_app_logs_ws_permission_rejected
from datetime import datetime, timezone
from pathlib import Path
import sys
from types import SimpleNamespace
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from starlette.websockets import WebSocketDisconnect
_DEFAULT_TS = datetime(2025, 1, 15, 10, 0, 0, tzinfo=timezone.utc)
# #region _make_log_line [C:1] [TYPE Function]
def _make_log_line(seq, level, raw, timestamp=_DEFAULT_TS, logger_name="cot"):
return SimpleNamespace(
seq=seq,
level=level,
raw=raw,
timestamp=timestamp,
logger_name=logger_name,
)
# #endregion _make_log_line
# #region _make_handler_mock [C:1] [TYPE Function]
def _make_handler_mock(lines, seq=3, capacity=3000):
handler = MagicMock()
handler.get_recent_logs.return_value = lines
handler.current_seq.return_value = seq
handler.get_since.return_value = (lines, seq)
handler.capacity = capacity
return handler
# #endregion _make_handler_mock
class TestGetRecentAppLogs:
"""get_recent_app_logs — REST snapshot of the app/cot log ring buffer."""
# #region Test.AppModule.TestRecentLogsNoFilters [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_recent_logs_no_filters(self):
from src.app import get_recent_app_logs
l1 = _make_log_line(1, "INFO", "one")
l2 = _make_log_line(2, "WARNING", "two")
handler = _make_handler_mock([l1, l2], seq=2)
with patch("src.core.logger.get_app_log_handler", return_value=handler):
result = await get_recent_app_logs(_user=None)
assert result["current_seq"] == 2
assert result["capacity"] == 3000
assert [i["seq"] for i in result["items"]] == [1, 2]
assert result["items"][0]["raw"] == "one"
assert result["items"][0]["logger"] == "cot"
assert result["items"][0]["timestamp"] == "2025-01-15T10:00:00+00:00"
# #endregion Test.AppModule.TestRecentLogsNoFilters
# #region Test.AppModule.TestRecentLogsLevelFilter [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_recent_logs_level_filter(self):
from src.app import get_recent_app_logs
lines = [
_make_log_line(1, "INFO", "a"),
_make_log_line(2, "ERROR", "b"),
_make_log_line(3, "WARNING", "c"),
]
handler = _make_handler_mock(lines, seq=3)
with patch("src.core.logger.get_app_log_handler", return_value=handler):
result = await get_recent_app_logs(level="ERROR", _user=None)
assert [i["level"] for i in result["items"]] == ["ERROR"]
# #endregion Test.AppModule.TestRecentLogsLevelFilter
# #region Test.AppModule.TestRecentLogsLevelCaseInsensitive [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_recent_logs_level_case_insensitive(self):
from src.app import get_recent_app_logs
lines = [
_make_log_line(1, "INFO", "a"),
_make_log_line(2, "WARNING", "b"),
_make_log_line(3, "ERROR", "c"),
]
handler = _make_handler_mock(lines, seq=3)
with patch("src.core.logger.get_app_log_handler", return_value=handler):
result = await get_recent_app_logs(level="warning", _user=None)
assert [i["level"] for i in result["items"]] == ["WARNING", "ERROR"]
# #endregion Test.AppModule.TestRecentLogsLevelCaseInsensitive
# #region Test.AppModule.TestRecentLogsUnknownLevel [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_recent_logs_unknown_level(self):
from src.app import get_recent_app_logs
lines = [_make_log_line(1, "INFO", "a"), _make_log_line(2, "ERROR", "b")]
handler = _make_handler_mock(lines, seq=2)
with patch("src.core.logger.get_app_log_handler", return_value=handler):
result = await get_recent_app_logs(level="verbose", _user=None)
# unknown requested level -> min_level 0 -> everything passes
assert len(result["items"]) == 2
# #endregion Test.AppModule.TestRecentLogsUnknownLevel
# #region Test.AppModule.TestRecentLogsTaskIdFilter [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_recent_logs_task_id_filter(self):
from src.app import get_recent_app_logs
lines = [
_make_log_line(1, "INFO", '{"task_id": "t1"}'),
_make_log_line(2, "INFO", '{"task_id": "t2"}'),
]
handler = _make_handler_mock(lines, seq=2)
with patch("src.core.logger.get_app_log_handler", return_value=handler):
result = await get_recent_app_logs(task_id="t1", _user=None)
assert [i["seq"] for i in result["items"]] == [1]
# #endregion Test.AppModule.TestRecentLogsTaskIdFilter
# #region Test.AppModule.TestRecentLogsMultipleTaskIds [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_recent_logs_multiple_task_ids(self):
from src.app import get_recent_app_logs
lines = [
_make_log_line(1, "INFO", '{"task_id": "t1"}'),
_make_log_line(2, "INFO", '{"task_id": "t2"}'),
_make_log_line(3, "INFO", '{"task_id": "t3"}'),
]
handler = _make_handler_mock(lines, seq=3)
with patch("src.core.logger.get_app_log_handler", return_value=handler):
result = await get_recent_app_logs(task_id="t1, t2", _user=None)
assert [i["seq"] for i in result["items"]] == [1, 2]
# #endregion Test.AppModule.TestRecentLogsMultipleTaskIds
# #region Test.AppModule.TestRecentLogsEmptyTaskIdNoFilter [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_recent_logs_empty_task_id_no_filter(self):
from src.app import get_recent_app_logs
lines = [_make_log_line(1, "INFO", "a"), _make_log_line(2, "INFO", "b")]
handler = _make_handler_mock(lines, seq=2)
with patch("src.core.logger.get_app_log_handler", return_value=handler):
result = await get_recent_app_logs(task_id=" , ", _user=None)
# comma-only task_id -> no task ids -> no filtering
assert len(result["items"]) == 2
# #endregion Test.AppModule.TestRecentLogsEmptyTaskIdNoFilter
# #region Test.AppModule.TestRecentLogsAfterSeq [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_recent_logs_after_seq_uses_get_since(self):
from src.app import get_recent_app_logs
newer = [_make_log_line(9, "INFO", "nine")]
handler = _make_handler_mock([], seq=9)
handler.get_since.return_value = (newer, 9)
with patch("src.core.logger.get_app_log_handler", return_value=handler):
result = await get_recent_app_logs(after_seq=5, _user=None)
handler.get_since.assert_called_once_with(5, limit=500)
handler.get_recent_logs.assert_not_called()
assert result["current_seq"] == 9
assert result["items"][0]["seq"] == 9
# #endregion Test.AppModule.TestRecentLogsAfterSeq
# #region Test.AppModule.TestRecentLogsLimitClampedHigh [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_recent_logs_limit_clamped_high(self):
from src.app import get_recent_app_logs
handler = _make_handler_mock([], seq=0)
with patch("src.core.logger.get_app_log_handler", return_value=handler):
await get_recent_app_logs(limit=5000, _user=None)
handler.get_recent_logs.assert_called_once_with(limit=3000)
# #endregion Test.AppModule.TestRecentLogsLimitClampedHigh
# #region Test.AppModule.TestRecentLogsLimitClampedLow [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_recent_logs_limit_clamped_low(self):
from src.app import get_recent_app_logs
handler = _make_handler_mock([], seq=0)
with patch("src.core.logger.get_app_log_handler", return_value=handler):
await get_recent_app_logs(limit=-5, _user=None)
handler.get_recent_logs.assert_called_once_with(limit=1)
# #endregion Test.AppModule.TestRecentLogsLimitClampedLow
# #region Test.AppModule.TestRecentLogsLimitZeroMeansDefault [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_recent_logs_limit_zero_means_default(self):
from src.app import get_recent_app_logs
handler = _make_handler_mock([], seq=0)
with patch("src.core.logger.get_app_log_handler", return_value=handler):
await get_recent_app_logs(limit=0, _user=None)
# falsy limit falls back to 500 before clamping
handler.get_recent_logs.assert_called_once_with(limit=500)
# #endregion Test.AppModule.TestRecentLogsLimitZeroMeansDefault
# #region Test.AppModule.TestRecentLogsTimestampAndRawNone [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_recent_logs_timestamp_and_raw_none(self):
from src.app import get_recent_app_logs
line = _make_log_line(1, "INFO", None, timestamp=None)
handler = _make_handler_mock([line], seq=1)
with patch("src.core.logger.get_app_log_handler", return_value=handler):
result = await get_recent_app_logs(_user=None)
assert result["items"][0]["timestamp"] is None
assert result["items"][0]["raw"] == ""
# #endregion Test.AppModule.TestRecentLogsTimestampAndRawNone
# #region Test.AppModule.TestRecentLogsUnknownLineLevel [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_recent_logs_unknown_line_level(self):
from src.app import get_recent_app_logs
line = _make_log_line(1, "TRACE", "x")
handler = _make_handler_mock([line], seq=1)
with patch("src.core.logger.get_app_log_handler", return_value=handler):
result = await get_recent_app_logs(level="ERROR", _user=None)
# TRACE maps to level 0 < min_level 3 -> dropped
assert result["items"] == []
# #endregion Test.AppModule.TestRecentLogsUnknownLineLevel
# #region Test.AppModule.TestRecentLogsEmpty [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_recent_logs_empty(self):
from src.app import get_recent_app_logs
handler = _make_handler_mock([], seq=0)
with patch("src.core.logger.get_app_log_handler", return_value=handler):
result = await get_recent_app_logs(_user=None)
assert result["items"] == []
assert result["current_seq"] == 0
# #endregion Test.AppModule.TestRecentLogsEmpty
class TestAppLogsWebSocket:
"""app_logs_websocket — live tail of the process-wide log ring buffer."""
# #region Test.AppModule.TestAppLogsWsAuthRejected [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_app_logs_ws_auth_rejected(self):
from src.app import app_logs_websocket
ws = MagicMock(); ws.query_params = {}
ws.accept = AsyncMock(); ws.close = AsyncMock()
with patch("src.app._authenticate_websocket", return_value=False):
await app_logs_websocket(ws)
ws.accept.assert_called_once()
ws.close.assert_called_once_with(code=4001, reason="Authentication required")
# #endregion Test.AppModule.TestAppLogsWsAuthRejected
# #region Test.AppModule.TestAppLogsWsPermissionRejected [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_app_logs_ws_permission_rejected(self):
from src.app import app_logs_websocket
ws = MagicMock(); ws.query_params = {"token": "valid"}
ws.accept = AsyncMock(); ws.close = AsyncMock()
with (
patch("src.app._authenticate_websocket", return_value=True),
patch("src.app._authorize_websocket", return_value=False),
):
await app_logs_websocket(ws)
ws.accept.assert_called_once()
ws.close.assert_called_once_with(code=4003, reason="Insufficient permissions")
# #endregion Test.AppModule.TestAppLogsWsPermissionRejected
# #region Test.AppModule.TestAppLogsWsStreamsRecentThenPolls [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_app_logs_ws_streams_recent_then_polls(self):
from src.app import app_logs_websocket
ws = MagicMock(); ws.query_params = {"token": "valid"}
ws.accept = AsyncMock()
l1 = _make_log_line(1, "INFO", "one")
l2 = _make_log_line(2, "INFO", "two")
l3 = _make_log_line(3, "WARNING", "three", timestamp=None)
handler = _make_handler_mock([l1, l2], seq=2)
handler.get_since.return_value = ([l3], 3)
# snapshot: 2 sends; poll iteration 1: 1 send; poll iteration 2: disconnect
ws.send_json = AsyncMock(side_effect=[None, None, None, WebSocketDisconnect()])
with (
patch("src.app._authenticate_websocket", return_value=True),
patch("src.app._authorize_websocket", return_value=True),
patch("src.core.logger.get_app_log_handler", return_value=handler),
patch("asyncio.sleep", new=AsyncMock()),
):
await app_logs_websocket(ws)
handler.get_since.assert_any_call(2, limit=500)
sent = [c[0][0] for c in ws.send_json.call_args_list if isinstance(c[0][0], dict)]
assert [s["seq"] for s in sent] == [1, 2, 3, 3]
assert sent[2]["timestamp"] is None
assert sent[2]["logger"] == "cot"
# #endregion Test.AppModule.TestAppLogsWsStreamsRecentThenPolls
# #region Test.AppModule.TestAppLogsWsLevelFilter [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_app_logs_ws_level_filter(self):
from src.app import app_logs_websocket
ws = MagicMock(); ws.query_params = {"token": "valid"}
ws.accept = AsyncMock()
dbg = _make_log_line(1, "DEBUG", "dbg")
inf = _make_log_line(2, "INFO", "inf")
err = _make_log_line(3, "ERROR", "err")
handler = _make_handler_mock([dbg, inf, err], seq=3)
handler.get_since.return_value = ([err], 3)
ws.send_json = AsyncMock(side_effect=[None, WebSocketDisconnect()])
with (
patch("src.app._authenticate_websocket", return_value=True),
patch("src.app._authorize_websocket", return_value=True),
patch("src.core.logger.get_app_log_handler", return_value=handler),
patch("asyncio.sleep", new=AsyncMock()),
):
await app_logs_websocket(ws, level="ERROR")
sent = [c[0][0] for c in ws.send_json.call_args_list if isinstance(c[0][0], dict)]
assert sent, "expected at least one forwarded line"
assert all(s["level"] == "ERROR" for s in sent)
# #endregion Test.AppModule.TestAppLogsWsLevelFilter
# #region Test.AppModule.TestAppLogsWsTaskIdFilter [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_app_logs_ws_task_id_filter(self):
from src.app import app_logs_websocket
ws = MagicMock(); ws.query_params = {"token": "valid"}
ws.accept = AsyncMock()
match = _make_log_line(1, "INFO", '{"task_id": "t1"}')
other = _make_log_line(2, "INFO", '{"task_id": "t2"}')
handler = _make_handler_mock([match, other], seq=2)
handler.get_since.return_value = ([match], 2)
ws.send_json = AsyncMock(side_effect=[None, WebSocketDisconnect()])
with (
patch("src.app._authenticate_websocket", return_value=True),
patch("src.app._authorize_websocket", return_value=True),
patch("src.core.logger.get_app_log_handler", return_value=handler),
patch("asyncio.sleep", new=AsyncMock()),
):
await app_logs_websocket(ws, task_id="t1")
sent = [c[0][0] for c in ws.send_json.call_args_list if isinstance(c[0][0], dict)]
assert sent, "expected at least one forwarded line"
assert all('"task_id": "t1"' in s["raw"] for s in sent)
# #endregion Test.AppModule.TestAppLogsWsTaskIdFilter
# #region Test.AppModule.TestAppLogsWsNoTaskIdNoFilter [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_app_logs_ws_no_task_id_no_filter(self):
from src.app import app_logs_websocket
ws = MagicMock(); ws.query_params = {"token": "valid"}
ws.accept = AsyncMock()
a = _make_log_line(1, "INFO", "aaa")
b = _make_log_line(2, "INFO", "bbb")
handler = _make_handler_mock([a, b], seq=2)
handler.get_since.return_value = ([b], 2)
ws.send_json = AsyncMock(side_effect=[None, None, WebSocketDisconnect()])
with (
patch("src.app._authenticate_websocket", return_value=True),
patch("src.app._authorize_websocket", return_value=True),
patch("src.core.logger.get_app_log_handler", return_value=handler),
patch("asyncio.sleep", new=AsyncMock()),
):
await app_logs_websocket(ws, task_id=" , ")
sent = [c[0][0] for c in ws.send_json.call_args_list if isinstance(c[0][0], dict)]
assert len(sent) == 3
# #endregion Test.AppModule.TestAppLogsWsNoTaskIdNoFilter
# #region Test.AppModule.TestAppLogsWsPollFiltersNonMatching [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_app_logs_ws_poll_filters_non_matching(self):
"""A non-matching line arriving during polling is dropped (1205->1204)."""
from src.app import app_logs_websocket
ws = MagicMock(); ws.query_params = {"token": "valid"}
ws.accept = AsyncMock()
dbg = _make_log_line(1, "DEBUG", "dbg")
inf = _make_log_line(2, "INFO", "inf")
err = _make_log_line(3, "ERROR", "err")
handler = _make_handler_mock([dbg, inf, err], seq=3)
handler.get_since.side_effect = [([dbg], 3), ([err], 3)]
ws.send_json = AsyncMock(side_effect=[None, WebSocketDisconnect()])
with (
patch("src.app._authenticate_websocket", return_value=True),
patch("src.app._authorize_websocket", return_value=True),
patch("src.core.logger.get_app_log_handler", return_value=handler),
patch("asyncio.sleep", new=AsyncMock()),
):
await app_logs_websocket(ws, level="ERROR")
# snapshot forwards only ERROR; poll iteration 1 drops DEBUG; iteration 2 forwards ERROR
sent = [c[0][0] for c in ws.send_json.call_args_list if isinstance(c[0][0], dict)]
assert sent, "expected at least one forwarded line"
assert all(s["level"] == "ERROR" for s in sent)
# #endregion Test.AppModule.TestAppLogsWsPollFiltersNonMatching
# #region Test.AppModule.TestAppLogsWsPollFailureReraises [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_app_logs_ws_poll_failure_reraises(self):
from src.app import app_logs_websocket
ws = MagicMock(); ws.query_params = {"token": "valid"}
ws.accept = AsyncMock(); ws.send_json = AsyncMock()
handler = _make_handler_mock([], seq=0)
handler.get_since.side_effect = RuntimeError("buffer gone")
with (
patch("src.app._authenticate_websocket", return_value=True),
patch("src.app._authorize_websocket", return_value=True),
patch("src.core.logger.get_app_log_handler", return_value=handler),
patch("asyncio.sleep", new=AsyncMock()),
patch("src.app.logger.explore") as mexplore,
):
with pytest.raises(RuntimeError, match="buffer gone"):
await app_logs_websocket(ws)
assert any("App log stream failed" in str(c) for c in mexplore.call_args_list)
# #endregion Test.AppModule.TestAppLogsWsPollFailureReraises
# #endregion Test.AppModule.LogsEndpoint