- ~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
434 lines
19 KiB
Python
434 lines
19 KiB
Python
# #region Test.AppModule.Lifespan [C:3] [TYPE Module] [SEMANTICS test,app,lifespan,admin,bootstrap]
|
|
# @BRIEF Tests for app.py — ensure_initial_admin_user + lifespan startup/shutdown.
|
|
# @RELATION BINDS_TO -> [App.AppModule]
|
|
# @TEST_EDGE: missing_env_flag -> ensure_initial_admin_user skips when INITIAL_ADMIN_CREATE is off
|
|
# @TEST_EDGE: missing_admin_creds -> logs warning when flag on but username/password missing
|
|
# @TEST_EDGE: existing_admin_user -> skips creation when user already exists
|
|
# @TEST_EDGE: admin_role_creation -> creates Admin role if missing
|
|
# @TEST_EDGE: db_rollback_on_error -> rolls back DB transaction on exception
|
|
# @TEST_INVARIANT: admin_bootstrap_guard -> VERIFIED_BY: test_ensure_initial_admin_user_flag_off, test_ensure_initial_admin_user_missing_creds
|
|
|
|
from pathlib import Path
|
|
import sys
|
|
import os
|
|
import asyncio
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
|
|
|
import pytest
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
from fastapi import FastAPI
|
|
|
|
|
|
# =============================================================================
|
|
# ensure_initial_admin_user
|
|
# =============================================================================
|
|
|
|
class TestEnsureInitialAdminUser:
|
|
"""ensure_initial_admin_user — environment-gated admin bootstrap."""
|
|
|
|
# #region Test.AppModule.TestFlagOff [C:2] [TYPE Function]
|
|
@patch.dict(os.environ, {"INITIAL_ADMIN_CREATE": "false"}, clear=False)
|
|
def test_flag_off(self):
|
|
from src.app import ensure_initial_admin_user
|
|
result = ensure_initial_admin_user()
|
|
assert result is None
|
|
# #endregion Test.AppModule.TestFlagOff
|
|
|
|
# #region Test.AppModule.TestFlagOnMissingCreds [C:2] [TYPE Function]
|
|
@patch.dict(os.environ, {
|
|
"INITIAL_ADMIN_CREATE": "true", "INITIAL_ADMIN_USERNAME": "", "INITIAL_ADMIN_PASSWORD": "",
|
|
}, clear=False)
|
|
def test_flag_on_missing_creds(self):
|
|
from src.app import ensure_initial_admin_user
|
|
assert ensure_initial_admin_user() is None
|
|
# #endregion Test.AppModule.TestFlagOnMissingCreds
|
|
|
|
# #region Test.AppModule.TestFlagOnOnlyUsername [C:2] [TYPE Function]
|
|
@patch.dict(os.environ, {
|
|
"INITIAL_ADMIN_CREATE": "1", "INITIAL_ADMIN_USERNAME": "admin", "INITIAL_ADMIN_PASSWORD": "",
|
|
}, clear=False)
|
|
def test_flag_on_only_username(self):
|
|
from src.app import ensure_initial_admin_user
|
|
assert ensure_initial_admin_user() is None
|
|
# #endregion Test.AppModule.TestFlagOnOnlyUsername
|
|
|
|
# #region Test.AppModule.TestCreatesAdminRoleAndUser [C:2] [TYPE Function]
|
|
@patch.dict(os.environ, {
|
|
"INITIAL_ADMIN_CREATE": "true", "INITIAL_ADMIN_USERNAME": "bootstrap-admin", "INITIAL_ADMIN_PASSWORD": "s3cret!",
|
|
}, clear=False)
|
|
def test_creates_admin_role_and_user(self):
|
|
mock_db = MagicMock()
|
|
mock_db.query.return_value.filter.return_value.first.side_effect = [None, None]
|
|
mock_db.add = MagicMock()
|
|
with patch("src.app.AuthSessionLocal", return_value=mock_db):
|
|
from src.app import ensure_initial_admin_user
|
|
ensure_initial_admin_user()
|
|
role_calls = [c for c in mock_db.add.call_args_list if c.args[0].__class__.__name__ == "Role"]
|
|
user_calls = [c for c in mock_db.add.call_args_list if c.args[0].__class__.__name__ == "User"]
|
|
assert len(role_calls) == 1
|
|
assert role_calls[0].args[0].name == "Admin"
|
|
assert len(user_calls) == 1
|
|
assert user_calls[0].args[0].username == "bootstrap-admin"
|
|
assert mock_db.commit.call_count == 2
|
|
# #endregion Test.AppModule.TestCreatesAdminRoleAndUser
|
|
|
|
# #region Test.AppModule.TestSkipsExistingUser [C:2] [TYPE Function]
|
|
@patch.dict(os.environ, {
|
|
"INITIAL_ADMIN_CREATE": "true", "INITIAL_ADMIN_USERNAME": "existing-admin", "INITIAL_ADMIN_PASSWORD": "s3cret!",
|
|
}, clear=False)
|
|
def test_skips_existing_user(self):
|
|
mock_db = MagicMock()
|
|
mock_db.query.return_value.filter.return_value.first.side_effect = [
|
|
MagicMock(name="Admin"), MagicMock(username="existing-admin"),
|
|
]
|
|
with patch("src.app.AuthSessionLocal", return_value=mock_db):
|
|
from src.app import ensure_initial_admin_user
|
|
ensure_initial_admin_user()
|
|
mock_db.add.assert_not_called()
|
|
mock_db.commit.assert_not_called()
|
|
# #endregion Test.AppModule.TestSkipsExistingUser
|
|
|
|
# #region Test.AppModule.TestRollbackOnError [C:2] [TYPE Function]
|
|
@patch.dict(os.environ, {
|
|
"INITIAL_ADMIN_CREATE": "true", "INITIAL_ADMIN_USERNAME": "admin", "INITIAL_ADMIN_PASSWORD": "s3cret!",
|
|
}, clear=False)
|
|
def test_rollback_on_error(self):
|
|
mock_db = MagicMock()
|
|
mock_db.query.return_value.filter.return_value.first.side_effect = Exception("DB crash")
|
|
with patch("src.app.AuthSessionLocal", return_value=mock_db):
|
|
from src.app import ensure_initial_admin_user
|
|
with pytest.raises(Exception, match="DB crash"):
|
|
ensure_initial_admin_user()
|
|
mock_db.rollback.assert_called_once()
|
|
mock_db.close.assert_called_once()
|
|
# #endregion Test.AppModule.TestRollbackOnError
|
|
|
|
# #region Test.AppModule.TestReusesExistingAdminRole [C:2] [TYPE Function]
|
|
@patch.dict(os.environ, {
|
|
"INITIAL_ADMIN_CREATE": "true", "INITIAL_ADMIN_USERNAME": "new-user", "INITIAL_ADMIN_PASSWORD": "s3cret!",
|
|
}, clear=False)
|
|
def test_reuses_existing_admin_role(self):
|
|
mock_db = MagicMock()
|
|
mock_db.query.return_value.filter.return_value.first.side_effect = [MagicMock(name="Admin"), None]
|
|
with patch("src.app.AuthSessionLocal", return_value=mock_db):
|
|
from src.app import ensure_initial_admin_user
|
|
ensure_initial_admin_user()
|
|
user_calls = [c for c in mock_db.add.call_args_list if c.args[0].__class__.__name__ == "User"]
|
|
role_calls = [c for c in mock_db.add.call_args_list if c.args[0].__class__.__name__ == "Role"]
|
|
assert len(user_calls) == 1
|
|
assert len(role_calls) == 0
|
|
mock_db.commit.assert_called_once()
|
|
# #endregion Test.AppModule.TestReusesExistingAdminRole
|
|
|
|
|
|
# =============================================================================
|
|
# lifespan
|
|
# =============================================================================
|
|
|
|
class TestLifespan:
|
|
"""Lifespan — startup/shutdown lifecycle."""
|
|
|
|
# #region Test.AppModule.TestLifespanStartupInitializesDeps [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_lifespan_startup_initializes_deps(self):
|
|
from src.app import lifespan
|
|
mock_app = MagicMock(spec=FastAPI)
|
|
with (
|
|
patch("src.app.seed_trace_id") as mock_seed,
|
|
patch("src.app.ensure_encryption_key") as mock_enc_key,
|
|
patch("src.app.init_db") as mock_init_db,
|
|
patch("src.app.ensure_initial_admin_user") as mock_admin,
|
|
patch("src.app.get_scheduler_service") as mock_get_sched,
|
|
):
|
|
mock_scheduler = MagicMock()
|
|
mock_get_sched.return_value = mock_scheduler
|
|
async with lifespan(mock_app):
|
|
pass
|
|
mock_seed.assert_called_once()
|
|
mock_enc_key.assert_called_once()
|
|
mock_init_db.assert_called_once()
|
|
mock_admin.assert_called_once()
|
|
mock_scheduler.start.assert_called_once()
|
|
mock_scheduler.stop.assert_called_once()
|
|
# #endregion Test.AppModule.TestLifespanStartupInitializesDeps
|
|
|
|
# #region Test.AppModule.TestLifespanStuckRunCleanup [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_lifespan_stuck_run_cleanup(self):
|
|
from src.app import lifespan
|
|
mock_app = MagicMock(spec=FastAPI)
|
|
mock_stuck = MagicMock()
|
|
mock_stuck.id = "run-stuck-1"
|
|
mock_stuck.status = "running"
|
|
mock_db = MagicMock()
|
|
mock_db.query.return_value.filter.return_value.all.return_value = [mock_stuck]
|
|
with (
|
|
patch("src.app.seed_trace_id"), patch("src.app.ensure_encryption_key"),
|
|
patch("src.app.init_db"), patch("src.app.ensure_initial_admin_user"),
|
|
patch("src.app.get_scheduler_service"),
|
|
patch("src.core.database.SessionLocal", return_value=mock_db),
|
|
):
|
|
async with lifespan(mock_app):
|
|
pass
|
|
assert mock_stuck.status == "FAIL"
|
|
assert "Force-stopped" in mock_stuck.summary
|
|
mock_db.commit.assert_called()
|
|
mock_db.close.assert_called()
|
|
# #endregion Test.AppModule.TestLifespanStuckRunCleanup
|
|
|
|
# #region Test.AppModule.TestLifespanStuckCleanupHandlesException [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_lifespan_stuck_cleanup_handles_exception(self):
|
|
from src.app import lifespan
|
|
mock_app = MagicMock(spec=FastAPI)
|
|
with (
|
|
patch("src.app.seed_trace_id"), patch("src.app.ensure_encryption_key"),
|
|
patch("src.app.init_db"), patch("src.app.ensure_initial_admin_user"),
|
|
patch("src.app.get_scheduler_service"),
|
|
patch("src.core.database.SessionLocal", side_effect=Exception("DB unreachable")),
|
|
):
|
|
async with lifespan(mock_app):
|
|
pass
|
|
# #endregion Test.AppModule.TestLifespanStuckCleanupHandlesException
|
|
|
|
# #region Test.AppModule.TestLifespanStuckNoRuns [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_lifespan_stuck_no_runs(self):
|
|
from src.app import lifespan
|
|
mock_app = MagicMock(spec=FastAPI)
|
|
mock_db = MagicMock()
|
|
mock_db.query.return_value.filter.return_value.all.return_value = []
|
|
with (
|
|
patch("src.app.seed_trace_id"), patch("src.app.ensure_encryption_key"),
|
|
patch("src.app.init_db"), patch("src.app.ensure_initial_admin_user"),
|
|
patch("src.app.get_scheduler_service"),
|
|
patch("src.core.database.SessionLocal", return_value=mock_db),
|
|
):
|
|
async with lifespan(mock_app):
|
|
pass
|
|
mock_db.commit.assert_called() # ≥1: RBAC permission sync also calls commit
|
|
mock_db.close.assert_called()
|
|
# #endregion Test.AppModule.TestLifespanStuckNoRuns
|
|
|
|
# #region Test.AppModule.TestLifespanShutdownStopsScheduler [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_lifespan_shutdown_stops_scheduler(self):
|
|
from src.app import lifespan
|
|
mock_app = MagicMock(spec=FastAPI)
|
|
mock_scheduler = MagicMock()
|
|
with (
|
|
patch("src.app.seed_trace_id"), patch("src.app.ensure_encryption_key"),
|
|
patch("src.app.init_db"), patch("src.app.ensure_initial_admin_user"),
|
|
patch("src.app.get_scheduler_service", return_value=mock_scheduler),
|
|
):
|
|
async with lifespan(mock_app):
|
|
pass
|
|
mock_scheduler.stop.assert_called_once()
|
|
# #endregion Test.AppModule.TestLifespanShutdownStopsScheduler
|
|
|
|
|
|
class TestLifespanStuckGeneralTasks:
|
|
"""lifespan — reconciliation of stuck general tasks (RUNNING -> FAILED)."""
|
|
|
|
def _base_patches(self, scheduler=None):
|
|
return [
|
|
patch("src.app.seed_trace_id"), patch("src.app.ensure_encryption_key"),
|
|
patch("src.app.init_db"), patch("src.app.ensure_initial_admin_user"),
|
|
patch("src.app.get_scheduler_service", return_value=scheduler or MagicMock()),
|
|
]
|
|
|
|
def _enter(self, stack, extra=None):
|
|
for p in [*self._base_patches(), *(extra or [])]:
|
|
stack.enter_context(p)
|
|
|
|
# #region Test.AppModule.TestStuckGeneralTaskFailed [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_stuck_general_task_failed(self):
|
|
from contextlib import ExitStack
|
|
from src.app import lifespan
|
|
mock_app = MagicMock(spec=FastAPI)
|
|
stuck = MagicMock()
|
|
stuck.id = "task-stuck-1"; stuck.status = "RUNNING"
|
|
stuck.result = None; stuck.type = "sync"
|
|
mock_db = MagicMock()
|
|
mock_db.query.return_value.filter.return_value.all.return_value = [stuck]
|
|
with ExitStack() as stack:
|
|
self._enter(stack, [patch("src.core.database.TasksSessionLocal", return_value=mock_db)])
|
|
async with lifespan(mock_app):
|
|
pass
|
|
assert stuck.status == "FAILED"
|
|
assert stuck.result == {"error": "interrupted_by_restart"}
|
|
assert "Force-stopped" in stuck.error
|
|
assert stuck.finished_at is not None
|
|
mock_db.commit.assert_called()
|
|
mock_db.close.assert_called()
|
|
# #endregion Test.AppModule.TestStuckGeneralTaskFailed
|
|
|
|
# #region Test.AppModule.TestStuckGeneralTaskKeepsResult [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_stuck_general_task_keeps_result(self):
|
|
from contextlib import ExitStack
|
|
from src.app import lifespan
|
|
mock_app = MagicMock(spec=FastAPI)
|
|
stuck = MagicMock()
|
|
stuck.id = "task-stuck-2"; stuck.status = "RUNNING"
|
|
stuck.result = {"partial": 1}; stuck.type = "sync"
|
|
mock_db = MagicMock()
|
|
mock_db.query.return_value.filter.return_value.all.return_value = [stuck]
|
|
with ExitStack() as stack:
|
|
self._enter(stack, [patch("src.core.database.TasksSessionLocal", return_value=mock_db)])
|
|
async with lifespan(mock_app):
|
|
pass
|
|
assert stuck.status == "FAILED"
|
|
# result already present -> hint not overwritten
|
|
assert stuck.result == {"partial": 1}
|
|
# #endregion Test.AppModule.TestStuckGeneralTaskKeepsResult
|
|
|
|
# #region Test.AppModule.TestStuckGeneralTaskNoneStuck [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_stuck_general_task_none_stuck(self):
|
|
from contextlib import ExitStack
|
|
from src.app import lifespan
|
|
mock_app = MagicMock(spec=FastAPI)
|
|
mock_db = MagicMock()
|
|
mock_db.query.return_value.filter.return_value.all.return_value = []
|
|
with ExitStack() as stack:
|
|
self._enter(stack, [patch("src.core.database.TasksSessionLocal", return_value=mock_db)])
|
|
async with lifespan(mock_app):
|
|
pass
|
|
mock_db.commit.assert_called()
|
|
mock_db.close.assert_called()
|
|
# #endregion Test.AppModule.TestStuckGeneralTaskNoneStuck
|
|
|
|
# #region Test.AppModule.TestStuckGeneralTaskCleanupException [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_stuck_general_task_cleanup_exception(self):
|
|
from contextlib import ExitStack
|
|
from src.app import lifespan
|
|
mock_app = MagicMock(spec=FastAPI)
|
|
with ExitStack() as stack:
|
|
mexplore = stack.enter_context(patch("src.app.logger.explore"))
|
|
self._enter(stack, [
|
|
patch("src.core.database.TasksSessionLocal", side_effect=Exception("DB unreachable")),
|
|
])
|
|
async with lifespan(mock_app):
|
|
pass
|
|
assert any("stuck general tasks" in str(c) for c in mexplore.call_args_list)
|
|
# #endregion Test.AppModule.TestStuckGeneralTaskCleanupException
|
|
|
|
|
|
class TestLifespanGracefulDrain:
|
|
"""lifespan shutdown — graceful task drain of the async task manager."""
|
|
|
|
def _base_patches(self, tm, cm=None):
|
|
return [
|
|
patch("src.app.seed_trace_id"), patch("src.app.ensure_encryption_key"),
|
|
patch("src.app.init_db"), patch("src.app.ensure_initial_admin_user"),
|
|
patch("src.app.get_scheduler_service", return_value=MagicMock()),
|
|
patch("src.dependencies.get_task_manager", return_value=tm),
|
|
patch("src.dependencies.get_config_manager", return_value=cm),
|
|
]
|
|
|
|
# #region Test.AppModule.TestDrainCancelsPending [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_drain_cancels_pending(self):
|
|
from contextlib import ExitStack
|
|
from src.app import lifespan
|
|
mock_app = MagicMock(spec=FastAPI)
|
|
|
|
async def _never():
|
|
await asyncio.sleep(3600)
|
|
|
|
pending = asyncio.create_task(_never())
|
|
tm = MagicMock()
|
|
tm._async_tasks = {"t1": pending}
|
|
cm = MagicMock()
|
|
cm.settings.graceful_shutdown_timeout = 0.1
|
|
with ExitStack() as stack:
|
|
for p in self._base_patches(tm, cm):
|
|
stack.enter_context(p)
|
|
async with lifespan(mock_app):
|
|
pass
|
|
assert pending.cancelled()
|
|
# #endregion Test.AppModule.TestDrainCancelsPending
|
|
|
|
# #region Test.AppModule.TestDrainNoRunningTasks [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_drain_no_running_tasks(self):
|
|
from contextlib import ExitStack
|
|
from src.app import lifespan
|
|
mock_app = MagicMock(spec=FastAPI)
|
|
tm = MagicMock()
|
|
tm._async_tasks = {}
|
|
with ExitStack() as stack:
|
|
for p in self._base_patches(tm, None):
|
|
stack.enter_context(p)
|
|
async with lifespan(mock_app):
|
|
pass
|
|
# #endregion Test.AppModule.TestDrainNoRunningTasks
|
|
|
|
# #region Test.AppModule.TestDrainCompletedTaskDefaultTimeout [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_drain_completed_task_default_timeout(self):
|
|
from contextlib import ExitStack
|
|
from src.app import lifespan
|
|
mock_app = MagicMock(spec=FastAPI)
|
|
|
|
async def _quick():
|
|
return 42
|
|
|
|
done = asyncio.create_task(_quick())
|
|
await asyncio.sleep(0) # let it finish
|
|
tm = MagicMock()
|
|
tm._async_tasks = {"t1": done}
|
|
cm = MagicMock()
|
|
cm.settings = object() # no graceful_shutdown_timeout attr -> default 30.0
|
|
with ExitStack() as stack:
|
|
for p in self._base_patches(tm, cm):
|
|
stack.enter_context(p)
|
|
async with lifespan(mock_app):
|
|
pass
|
|
assert not done.cancelled()
|
|
# #endregion Test.AppModule.TestDrainCompletedTaskDefaultTimeout
|
|
|
|
# #region Test.AppModule.TestDrainNoConfigManager [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_drain_no_config_manager(self):
|
|
from contextlib import ExitStack
|
|
from src.app import lifespan
|
|
mock_app = MagicMock(spec=FastAPI)
|
|
|
|
async def _quick():
|
|
return 1
|
|
|
|
done = asyncio.create_task(_quick())
|
|
await asyncio.sleep(0)
|
|
tm = MagicMock()
|
|
tm._async_tasks = {"t1": done}
|
|
with ExitStack() as stack:
|
|
for p in self._base_patches(tm, None):
|
|
stack.enter_context(p)
|
|
async with lifespan(mock_app):
|
|
pass
|
|
assert not done.cancelled()
|
|
# #endregion Test.AppModule.TestDrainNoConfigManager
|
|
|
|
# #region Test.AppModule.TestDrainExceptionLogged [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_drain_exception_logged(self):
|
|
from src.app import lifespan
|
|
mock_app = MagicMock(spec=FastAPI)
|
|
with (
|
|
patch("src.app.seed_trace_id"), patch("src.app.ensure_encryption_key"),
|
|
patch("src.app.init_db"), patch("src.app.ensure_initial_admin_user"),
|
|
patch("src.app.get_scheduler_service", return_value=MagicMock()),
|
|
patch("src.dependencies.get_task_manager", side_effect=Exception("drain boom")),
|
|
patch("src.app.logger.explore") as mexplore,
|
|
):
|
|
async with lifespan(mock_app):
|
|
pass
|
|
assert any("Graceful task drain" in str(c) for c in mexplore.call_args_list)
|
|
# #endregion Test.AppModule.TestDrainExceptionLogged
|
|
# #endregion Test.AppModule.Lifespan
|