fix(core): centralize async/sync bridge for APScheduler scheduled jobs
Create AsyncJobRunner — centralized bridge between APScheduler (BackgroundScheduler, sync thread pool) and async coroutines. Fixes: - P0: execute_run() called without await from APScheduler thread, causing coroutine to be silently discarded (root cause: no translation history) - P0: get_async_job_runner() deadlock when called from APScheduler thread pool without running event loop - P1: ID mismatch in disable_schedule/delete_schedule routes (job_id passed instead of schedule_id) - P1: asyncio.run() in APScheduler callbacks incompatible with running event loop - Delete unused llm_analysis/scheduler.py (not used in production) Changes: core/async_job_runner.py — new: AsyncJobRunner class core/scheduler.py — use runner.run()/run_later() translate/scheduler.py — use runner.run() for execute_run mapping_service.py — remove unused BackgroundScheduler dependencies.py — add get_async_job_runner() DI app.py — init runner in lifespan api/routes/migration.py — use runner.run() _schedule_routes.py — fix ID mismatch plugins/migration.py — use runner.run() llm_analysis/scheduler.py — delete (unused) tests: 151 new/updated tests, all passing
This commit is contained in:
256
backend/tests/core/test_async_job_runner.py
Normal file
256
backend/tests/core/test_async_job_runner.py
Normal file
@@ -0,0 +1,256 @@
|
||||
# #region Test.AsyncJobRunner [C:3] [TYPE Module] [SEMANTICS test,async,runner,scheduler]
|
||||
# @BRIEF Unit tests for AsyncJobRunner — centralized async/sync bridge.
|
||||
# @RELATION BINDS_TO -> [AsyncJobRunner]
|
||||
# @TEST_CONTRACT: AsyncJobRunner.run -> result | executes coroutine and returns result
|
||||
# @TEST_CONTRACT: AsyncJobRunner.run_later -> None | schedules delayed coroutine execution
|
||||
# @TEST_EDGE: run_propagates_exception -> exception from coroutine is re-raised
|
||||
# @TEST_EDGE: run_later_executes_after_delay -> coroutine runs after specified delay
|
||||
# @TEST_EDGE: run_from_background_thread -> works correctly from non-main thread
|
||||
# @TEST_INVARIANT: coroutine_must_be_awaited -> VERIFIED_BY: [test_run_executes_coroutine, test_run_propagates_exception]
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
||||
|
||||
from src.core.async_job_runner import AsyncJobRunner
|
||||
|
||||
|
||||
# -- Helpers -----------------------------------------------------------------
|
||||
|
||||
async def async_return_value(value):
|
||||
"""Simple async function that returns a value."""
|
||||
return value
|
||||
|
||||
|
||||
async def async_raise(exc_class, message=""):
|
||||
"""Simple async function that raises an exception."""
|
||||
raise exc_class(message)
|
||||
|
||||
|
||||
async def async_delay(value, delay_seconds):
|
||||
"""Async function that sleeps then returns a value."""
|
||||
await asyncio.sleep(delay_seconds)
|
||||
return value
|
||||
|
||||
|
||||
# -- Fixtures ----------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def event_loop():
|
||||
"""Create a new event loop for each test."""
|
||||
loop = asyncio.new_event_loop()
|
||||
yield loop
|
||||
loop.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def runner(event_loop):
|
||||
"""Create an AsyncJobRunner with a new event loop."""
|
||||
return AsyncJobRunner(event_loop)
|
||||
|
||||
|
||||
# -- Tests: run() ------------------------------------------------------------
|
||||
|
||||
# #region test_run_executes_coroutine [C:2] [TYPE Function]
|
||||
# @BRIEF run() executes a coroutine and returns its result.
|
||||
def test_run_executes_coroutine(runner, event_loop):
|
||||
"""run() dispatches coroutine to event loop and waits for result."""
|
||||
# Start the event loop in a background thread
|
||||
thread = threading.Thread(target=event_loop.run_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
result = runner.run(async_return_value(42))
|
||||
assert result == 42
|
||||
finally:
|
||||
event_loop.call_soon_threadsafe(event_loop.stop)
|
||||
thread.join(timeout=2)
|
||||
# #endregion test_run_executes_coroutine
|
||||
|
||||
|
||||
# #region test_run_returns_string [C:2] [TYPE Function]
|
||||
# @BRIEF run() works with string return values.
|
||||
def test_run_returns_string(runner, event_loop):
|
||||
"""run() correctly returns string results."""
|
||||
thread = threading.Thread(target=event_loop.run_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
result = runner.run(async_return_value("hello"))
|
||||
assert result == "hello"
|
||||
finally:
|
||||
event_loop.call_soon_threadsafe(event_loop.stop)
|
||||
thread.join(timeout=2)
|
||||
# #endregion test_run_returns_string
|
||||
|
||||
|
||||
# #region test_run_returns_none [C:2] [TYPE Function]
|
||||
# @BRIEF run() works when coroutine returns None.
|
||||
def test_run_returns_none(runner, event_loop):
|
||||
"""run() correctly handles None return value."""
|
||||
async def async_none():
|
||||
return None
|
||||
|
||||
thread = threading.Thread(target=event_loop.run_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
result = runner.run(async_none())
|
||||
assert result is None
|
||||
finally:
|
||||
event_loop.call_soon_threadsafe(event_loop.stop)
|
||||
thread.join(timeout=2)
|
||||
# #endregion test_run_returns_none
|
||||
|
||||
|
||||
# #region test_run_propagates_exception [C:2] [TYPE Function]
|
||||
# @BRIEF run() re-raises exceptions from the coroutine.
|
||||
def test_run_propagates_exception(runner, event_loop):
|
||||
"""run() re-raises exceptions from the coroutine in the calling thread."""
|
||||
thread = threading.Thread(target=event_loop.run_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
with pytest.raises(ValueError, match="boom"):
|
||||
runner.run(async_raise(ValueError, "boom"))
|
||||
finally:
|
||||
event_loop.call_soon_threadsafe(event_loop.stop)
|
||||
thread.join(timeout=2)
|
||||
# #endregion test_run_propagates_exception
|
||||
|
||||
|
||||
# #region test_run_propagates_runtime_error [C:2] [TYPE Function]
|
||||
# @BRIEF run() re-raises RuntimeError from the coroutine.
|
||||
def test_run_propagates_runtime_error(runner, event_loop):
|
||||
"""run() re-raises RuntimeError from the coroutine."""
|
||||
thread = threading.Thread(target=event_loop.run_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
with pytest.raises(RuntimeError, match="connection lost"):
|
||||
runner.run(async_raise(RuntimeError, "connection lost"))
|
||||
finally:
|
||||
event_loop.call_soon_threadsafe(event_loop.stop)
|
||||
thread.join(timeout=2)
|
||||
# #endregion test_run_propagates_runtime_error
|
||||
|
||||
|
||||
# -- Tests: run_later() ------------------------------------------------------
|
||||
|
||||
# #region test_run_later_executes_after_delay [C:2] [TYPE Function]
|
||||
# @BRIEF run_later() schedules a coroutine to execute after a delay.
|
||||
def test_run_later_executes_after_delay(runner, event_loop):
|
||||
"""run_later() dispatches coroutine after specified delay."""
|
||||
called = []
|
||||
|
||||
async def track():
|
||||
called.append(True)
|
||||
|
||||
thread = threading.Thread(target=event_loop.run_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
runner.run_later(track(), delay_seconds=0.1)
|
||||
time.sleep(0.3)
|
||||
assert len(called) == 1, f"Expected coroutine to execute once, got {len(called)} times"
|
||||
finally:
|
||||
event_loop.call_soon_threadsafe(event_loop.stop)
|
||||
thread.join(timeout=2)
|
||||
# #endregion test_run_later_executes_after_delay
|
||||
|
||||
|
||||
# #region test_run_later_does_not_execute_immediately [C:2] [TYPE Function]
|
||||
# @BRIEF run_later() does not execute coroutine before the delay.
|
||||
def test_run_later_does_not_execute_immediately(runner, event_loop):
|
||||
"""run_later() does not execute coroutine before the delay expires."""
|
||||
called = []
|
||||
|
||||
async def track():
|
||||
called.append(True)
|
||||
|
||||
thread = threading.Thread(target=event_loop.run_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
runner.run_later(track(), delay_seconds=0.5)
|
||||
time.sleep(0.1) # Check before delay expires
|
||||
assert len(called) == 0, "Coroutine should not have executed yet"
|
||||
finally:
|
||||
event_loop.call_soon_threadsafe(event_loop.stop)
|
||||
thread.join(timeout=2)
|
||||
# #endregion test_run_later_does_not_execute_immediately
|
||||
|
||||
|
||||
# -- Tests: from background thread ------------------------------------------
|
||||
|
||||
# #region test_run_from_background_thread [C:2] [TYPE Function]
|
||||
# @BRIEF run() works correctly when called from a non-main thread.
|
||||
def test_run_from_background_thread(event_loop):
|
||||
"""run() can be called from a background thread (simulating APScheduler)."""
|
||||
runner = AsyncJobRunner(event_loop)
|
||||
results = []
|
||||
errors = []
|
||||
|
||||
def background_work():
|
||||
try:
|
||||
result = runner.run(async_return_value(99))
|
||||
results.append(result)
|
||||
except Exception as e:
|
||||
errors.append(e)
|
||||
|
||||
# Start the event loop in a background thread
|
||||
loop_thread = threading.Thread(target=event_loop.run_forever, daemon=True)
|
||||
loop_thread.start()
|
||||
|
||||
try:
|
||||
# Call run() from another background thread (simulating APScheduler)
|
||||
work_thread = threading.Thread(target=background_work)
|
||||
work_thread.start()
|
||||
work_thread.join(timeout=5)
|
||||
|
||||
assert len(errors) == 0, f"Unexpected errors: {errors}"
|
||||
assert results == [99], f"Expected [99], got {results}"
|
||||
finally:
|
||||
event_loop.call_soon_threadsafe(event_loop.stop)
|
||||
loop_thread.join(timeout=2)
|
||||
# #endregion test_run_from_background_thread
|
||||
|
||||
|
||||
# -- Tests: edge cases -------------------------------------------------------
|
||||
|
||||
# #region test_run_with_complex_return_value [C:2] [TYPE Function]
|
||||
# @BRIEF run() works with complex return values (dict, list).
|
||||
def test_run_with_complex_return_value(runner, event_loop):
|
||||
"""run() correctly returns complex objects."""
|
||||
async def async_dict():
|
||||
return {"key": "value", "nested": [1, 2, 3]}
|
||||
|
||||
thread = threading.Thread(target=event_loop.run_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
result = runner.run(async_dict())
|
||||
assert result == {"key": "value", "nested": [1, 2, 3]}
|
||||
finally:
|
||||
event_loop.call_soon_threadsafe(event_loop.stop)
|
||||
thread.join(timeout=2)
|
||||
# #endregion test_run_with_complex_return_value
|
||||
|
||||
|
||||
# #region test_run_later_with_zero_delay [C:2] [TYPE Function]
|
||||
# @BRIEF run_later() with zero delay executes immediately.
|
||||
def test_run_later_with_zero_delay(runner, event_loop):
|
||||
"""run_later() with delay_seconds=0 executes immediately."""
|
||||
called = []
|
||||
|
||||
async def track():
|
||||
called.append(True)
|
||||
|
||||
thread = threading.Thread(target=event_loop.run_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
runner.run_later(track(), delay_seconds=0)
|
||||
time.sleep(0.2)
|
||||
assert len(called) == 1, "Coroutine should have executed immediately"
|
||||
finally:
|
||||
event_loop.call_soon_threadsafe(event_loop.stop)
|
||||
thread.join(timeout=2)
|
||||
# #endregion test_run_later_with_zero_delay
|
||||
188
backend/tests/core/test_async_job_runner_integration.py
Normal file
188
backend/tests/core/test_async_job_runner_integration.py
Normal file
@@ -0,0 +1,188 @@
|
||||
# #region Test.AsyncJobRunnerIntegration [C:3] [TYPE Module] [SEMANTICS test,async,runner,integration]
|
||||
# @BRIEF Integration tests for AsyncJobRunner with real event loop and threading.
|
||||
# @RELATION BINDS_TO -> [AsyncJobRunner]
|
||||
# @TEST_CONTRACT: AsyncJobRunner.run_with_real_loop -> result | works with real asyncio event loop
|
||||
# @TEST_CONTRACT: AsyncJobRunner.run_later_with_real_loop -> result | delayed execution works
|
||||
# @TEST_EDGE: concurrent_runs -> multiple run() calls from different threads
|
||||
# @TEST_EDGE: run_with_await -> coroutine with await works correctly
|
||||
# @TEST_INVARIANT: thread_safety -> VERIFIED_BY: [test_concurrent_runs_from_multiple_threads]
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
||||
|
||||
from src.core.async_job_runner import AsyncJobRunner
|
||||
|
||||
|
||||
# -- Helpers -----------------------------------------------------------------
|
||||
|
||||
async def async_db_operation(value, delay=0.01):
|
||||
"""Simulate an async DB operation."""
|
||||
await asyncio.sleep(delay)
|
||||
return {"result": value, "status": "ok"}
|
||||
|
||||
|
||||
async def async_chain_operation(value):
|
||||
"""Simulate chained async operations."""
|
||||
result1 = await async_db_operation(value)
|
||||
result2 = await async_db_operation(result1["result"] * 2)
|
||||
return result2
|
||||
|
||||
|
||||
# -- Tests: Integration with real event loop ---------------------------------
|
||||
|
||||
# #region test_run_with_real_event_loop [C:2] [TYPE Function]
|
||||
# @BRIEF run() works with a real asyncio event loop running in a background thread.
|
||||
def test_run_with_real_event_loop():
|
||||
"""run() dispatches coroutine to a real event loop and waits for result."""
|
||||
loop = asyncio.new_event_loop()
|
||||
runner = AsyncJobRunner(loop)
|
||||
|
||||
def run_loop():
|
||||
loop.run_forever()
|
||||
|
||||
loop_thread = threading.Thread(target=run_loop, daemon=True)
|
||||
loop_thread.start()
|
||||
|
||||
try:
|
||||
result = runner.run(async_db_operation("test"))
|
||||
assert result == {"result": "test", "status": "ok"}
|
||||
finally:
|
||||
loop.call_soon_threadsafe(loop.stop)
|
||||
loop_thread.join(timeout=2)
|
||||
loop.close()
|
||||
# #endregion test_run_with_real_event_loop
|
||||
|
||||
|
||||
# #region test_run_later_with_real_event_loop [C:2] [TYPE Function]
|
||||
# @BRIEF run_later() works with a real event loop — coroutine executes after delay.
|
||||
def test_run_later_with_real_event_loop():
|
||||
"""run_later() schedules coroutine on real event loop with correct timing."""
|
||||
loop = asyncio.new_event_loop()
|
||||
runner = AsyncJobRunner(loop)
|
||||
execution_times = []
|
||||
|
||||
async def track_time():
|
||||
execution_times.append(time.monotonic())
|
||||
|
||||
def run_loop():
|
||||
loop.run_forever()
|
||||
|
||||
loop_thread = threading.Thread(target=run_loop, daemon=True)
|
||||
loop_thread.start()
|
||||
|
||||
try:
|
||||
start = time.monotonic()
|
||||
runner.run_later(track_time(), delay_seconds=0.2)
|
||||
time.sleep(0.5)
|
||||
assert len(execution_times) == 1
|
||||
elapsed = execution_times[0] - start
|
||||
assert 0.15 < elapsed < 0.4, f"Expected ~0.2s delay, got {elapsed:.3f}s"
|
||||
finally:
|
||||
loop.call_soon_threadsafe(loop.stop)
|
||||
loop_thread.join(timeout=2)
|
||||
loop.close()
|
||||
# #endregion test_run_later_with_real_event_loop
|
||||
|
||||
|
||||
# #region test_concurrent_runs_from_multiple_threads [C:2] [TYPE Function]
|
||||
# @BRIEF Multiple threads can call run() concurrently without deadlocks.
|
||||
def test_concurrent_runs_from_multiple_threads():
|
||||
"""run() is thread-safe — multiple threads can dispatch coroutines concurrently."""
|
||||
loop = asyncio.new_event_loop()
|
||||
runner = AsyncJobRunner(loop)
|
||||
results = {}
|
||||
errors = []
|
||||
|
||||
def worker(thread_id):
|
||||
try:
|
||||
result = runner.run(async_db_operation(f"thread-{thread_id}"))
|
||||
results[thread_id] = result
|
||||
except Exception as e:
|
||||
errors.append((thread_id, e))
|
||||
|
||||
def run_loop():
|
||||
loop.run_forever()
|
||||
|
||||
loop_thread = threading.Thread(target=run_loop, daemon=True)
|
||||
loop_thread.start()
|
||||
|
||||
try:
|
||||
threads = []
|
||||
for i in range(5):
|
||||
t = threading.Thread(target=worker, args=(i,))
|
||||
threads.append(t)
|
||||
t.start()
|
||||
|
||||
for t in threads:
|
||||
t.join(timeout=10)
|
||||
|
||||
assert len(errors) == 0, f"Errors in threads: {errors}"
|
||||
assert len(results) == 5
|
||||
for i in range(5):
|
||||
assert results[i]["result"] == f"thread-{i}"
|
||||
finally:
|
||||
loop.call_soon_threadsafe(loop.stop)
|
||||
loop_thread.join(timeout=2)
|
||||
loop.close()
|
||||
# #endregion test_concurrent_runs_from_multiple_threads
|
||||
|
||||
|
||||
# #region test_run_with_chained_await [C:2] [TYPE Function]
|
||||
# @BRIEF run() works with coroutines that await other coroutines.
|
||||
def test_run_with_chained_await():
|
||||
"""run() correctly handles coroutines that internally await other coroutines."""
|
||||
loop = asyncio.new_event_loop()
|
||||
runner = AsyncJobRunner(loop)
|
||||
|
||||
def run_loop():
|
||||
loop.run_forever()
|
||||
|
||||
loop_thread = threading.Thread(target=run_loop, daemon=True)
|
||||
loop_thread.start()
|
||||
|
||||
try:
|
||||
result = runner.run(async_chain_operation(5))
|
||||
assert result == {"result": 10, "status": "ok"}
|
||||
finally:
|
||||
loop.call_soon_threadsafe(loop.stop)
|
||||
loop_thread.join(timeout=2)
|
||||
loop.close()
|
||||
# #endregion test_run_with_chained_await
|
||||
|
||||
|
||||
# #region test_run_later_multiple_tasks [C:2] [TYPE Function]
|
||||
# @BRIEF Multiple run_later() calls schedule multiple coroutines correctly.
|
||||
def test_run_later_multiple_tasks():
|
||||
"""Multiple run_later() calls all execute their coroutines."""
|
||||
loop = asyncio.new_event_loop()
|
||||
runner = AsyncJobRunner(loop)
|
||||
execution_order = []
|
||||
|
||||
async def track(name):
|
||||
execution_order.append(name)
|
||||
|
||||
def run_loop():
|
||||
loop.run_forever()
|
||||
|
||||
loop_thread = threading.Thread(target=run_loop, daemon=True)
|
||||
loop_thread.start()
|
||||
|
||||
try:
|
||||
runner.run_later(track("first"), delay_seconds=0.1)
|
||||
runner.run_later(track("second"), delay_seconds=0.2)
|
||||
runner.run_later(track("third"), delay_seconds=0.3)
|
||||
time.sleep(0.5)
|
||||
assert len(execution_order) == 3
|
||||
assert execution_order == ["first", "second", "third"]
|
||||
finally:
|
||||
loop.call_soon_threadsafe(loop.stop)
|
||||
loop_thread.join(timeout=2)
|
||||
loop.close()
|
||||
# #endregion test_run_later_multiple_tasks
|
||||
@@ -360,89 +360,6 @@ async def test_sync_environment_deletes_stale_mappings(db_session):
|
||||
# #endregion test_sync_environment_deletes_stale_mappings
|
||||
|
||||
|
||||
# #region test_start_scheduler_basic [C:2] [TYPE Function]
|
||||
# @RELATION BINDS_TO ->[TestMappingService]
|
||||
@patch("src.core.mapping_service.seed_trace_id")
|
||||
@patch("src.core.mapping_service.BackgroundScheduler")
|
||||
def test_start_scheduler_basic(mock_scheduler_cls, mock_seed, db_session):
|
||||
"""start_scheduler adds job and starts scheduler."""
|
||||
mock_scheduler = MagicMock()
|
||||
mock_scheduler_cls.return_value = mock_scheduler
|
||||
mock_scheduler.running = False
|
||||
|
||||
service = IdMappingService(db_session)
|
||||
mock_factory = MagicMock(return_value=MagicMock())
|
||||
service.start_scheduler("0 */5 * * *", ["env1", "env2"], mock_factory)
|
||||
|
||||
mock_scheduler.add_job.assert_called_once()
|
||||
mock_scheduler.start.assert_called_once()
|
||||
|
||||
|
||||
# #endregion test_start_scheduler_basic
|
||||
|
||||
|
||||
# #region test_start_scheduler_sync_all_called [C:2] [TYPE Function]
|
||||
# @RELATION BINDS_TO ->[TestMappingService]
|
||||
def test_start_scheduler_sync_all_called(db_session):
|
||||
"""sync_all inner function executed via scheduler.add_job (lines 75-80)."""
|
||||
mock_scheduler = MagicMock()
|
||||
mock_scheduler.running = False
|
||||
|
||||
captured_fn = [None]
|
||||
|
||||
def _capture_job(fn, *args, **kwargs):
|
||||
captured_fn[0] = fn
|
||||
return MagicMock(id="sync-job")
|
||||
|
||||
mock_scheduler.add_job.side_effect = _capture_job
|
||||
|
||||
mock_factory = MagicMock(return_value=MagicMock())
|
||||
|
||||
with patch("src.core.mapping_service.seed_trace_id") as mock_seed, \
|
||||
patch("src.core.mapping_service.BackgroundScheduler", return_value=mock_scheduler):
|
||||
|
||||
service = IdMappingService(db_session)
|
||||
with patch.object(service, 'sync_environment', return_value=None) as mock_sync:
|
||||
service.start_scheduler("0 */5 * * *", ["env1", "env2"], mock_factory)
|
||||
# Call the captured sync_all
|
||||
captured_fn[0]()
|
||||
|
||||
mock_seed.assert_called_once()
|
||||
assert mock_factory.call_count == 2
|
||||
mock_factory.assert_any_call("env1")
|
||||
mock_factory.assert_any_call("env2")
|
||||
assert mock_sync.call_count == 2
|
||||
# #endregion test_start_scheduler_sync_all_called
|
||||
|
||||
|
||||
# #region test_start_scheduler_update_existing [C:2] [TYPE Function]
|
||||
# @RELATION BINDS_TO ->[TestMappingService]
|
||||
@patch("src.core.mapping_service.seed_trace_id")
|
||||
@patch("src.core.mapping_service.BackgroundScheduler")
|
||||
def test_start_scheduler_update_existing(mock_scheduler_cls, mock_seed, db_session):
|
||||
"""start_scheduler with existing job → updates, doesn't re-start scheduler."""
|
||||
mock_scheduler = MagicMock()
|
||||
mock_scheduler_cls.return_value = mock_scheduler
|
||||
mock_scheduler.running = False # Not running initially
|
||||
|
||||
service = IdMappingService(db_session)
|
||||
mock_factory = MagicMock(return_value=MagicMock())
|
||||
service.start_scheduler("0 */5 * * *", ["env1"], mock_factory)
|
||||
|
||||
# First call should start the scheduler (was not running)
|
||||
mock_scheduler.start.assert_called_once()
|
||||
|
||||
# Second call with existing job — scheduler already running
|
||||
mock_scheduler.running = True
|
||||
service.start_scheduler("0 */10 * * *", ["env1"], mock_factory)
|
||||
|
||||
mock_scheduler.remove_job.assert_called_once() # called because _sync_job exists
|
||||
assert mock_scheduler.add_job.call_count == 2
|
||||
|
||||
|
||||
# #endregion test_start_scheduler_update_existing
|
||||
|
||||
|
||||
# #region test_get_remote_id_value_error [C:2] [TYPE Function]
|
||||
# @RELATION BINDS_TO ->[TestMappingService]
|
||||
def test_get_remote_id_value_error(db_session):
|
||||
|
||||
Reference in New Issue
Block a user