mirror of
https://github.com/TencentCloud/Octop.git
synced 2026-10-23 06:50:19 +08:00
Random gaps that missed the care window were pinned to the opening two hours, and a push could land in the middle of a conversation. Wait for an idle chat inside care hours, including windows that cross midnight, and drop the message if the user starts talking while it is being written. Co-authored-by: Cursor <cursoragent@cursor.com>
597 lines
20 KiB
Python
597 lines
20 KiB
Python
"""Unit tests for ProactiveCareScheduler.
|
||
|
||
Coverage:
|
||
- compute_next_trigger: random scheduling inside active hours
|
||
- compute_next_trigger: defer to the next active window when outside active hours
|
||
- is_in_active_hours: active-window checks
|
||
- ProactiveCareScheduler: enabled=false should not trigger
|
||
- ProactiveCareScheduler: dynamic reschedule updates
|
||
- ProactiveCareConfigRepo: get/upsert/list_enabled
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
from datetime import UTC, datetime, time, timedelta
|
||
from pathlib import Path
|
||
from unittest.mock import AsyncMock
|
||
from zoneinfo import ZoneInfo
|
||
|
||
import pytest
|
||
|
||
from octop.infra.db.migrate import run_migrations
|
||
from octop.infra.db.pool import SqlitePool
|
||
from octop.infra.db.repos.agents import AgentRepo
|
||
from octop.infra.db.repos.proactive_care_config import ProactiveCareConfig, ProactiveCareConfigRepo
|
||
from octop.infra.db.repos.users import UserRepo
|
||
from octop.infra.proactive.scheduler import (
|
||
ProactiveCareScheduler,
|
||
compute_next_trigger,
|
||
decide_send_timing,
|
||
is_in_active_hours,
|
||
)
|
||
from octop.infra.utils.ulid import new_ulid
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Fixtures
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.fixture
|
||
def db(tmp_path: Path) -> SqlitePool:
|
||
pool = SqlitePool(tmp_path / "test.db")
|
||
run_migrations(pool)
|
||
return pool
|
||
|
||
|
||
@pytest.fixture
|
||
def agent_id(db: SqlitePool) -> str:
|
||
uid = UserRepo(db).create(username="alice", password_hash="h", role="admin")
|
||
aid = new_ulid()
|
||
AgentRepo(db).create(agent_id=aid, user_id=uid, name="bot")
|
||
return aid
|
||
|
||
|
||
@pytest.fixture
|
||
def config_repo(db: SqlitePool) -> ProactiveCareConfigRepo:
|
||
return ProactiveCareConfigRepo(db)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# compute_next_trigger tests
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_compute_next_trigger_in_active_hours():
|
||
"""Should return the candidate time directly when it is inside active hours."""
|
||
# Current time is UTC 10:00, active window is 09:00-22:00, interval is 1h.
|
||
now = datetime(2026, 7, 1, 10, 0, 0, tzinfo=UTC)
|
||
# Fix the random seed so the interval stays inside active hours.
|
||
import random
|
||
|
||
random.seed(42)
|
||
# Force the interval to 60 minutes, which remains inside active hours.
|
||
with pytest.MonkeyPatch.context() as mp:
|
||
mp.setattr("random.randint", lambda a, b: 60) # Fixed 60 minutes.
|
||
result = compute_next_trigger(
|
||
now=now,
|
||
active_hours_start="09:00",
|
||
active_hours_end="22:00",
|
||
min_interval_hours=1,
|
||
max_interval_hours=2,
|
||
)
|
||
# The result should remain inside active hours.
|
||
result_t = result.time().replace(second=0, microsecond=0)
|
||
assert time(9, 0) <= result_t < time(22, 0)
|
||
|
||
|
||
def test_compute_next_trigger_outside_active_hours_deferred():
|
||
"""Should defer to the next active window when the candidate is outside active hours."""
|
||
# Current time is UTC 14:00, active window is 09:00-22:00, interval is 10h.
|
||
now = datetime(2026, 7, 1, 14, 0, 0, tzinfo=UTC)
|
||
with pytest.MonkeyPatch.context() as mp:
|
||
mp.setattr("random.randint", lambda a, b: 600) # Fixed 600 minutes = 10h.
|
||
result = compute_next_trigger(
|
||
now=now,
|
||
active_hours_start="09:00",
|
||
active_hours_end="22:00",
|
||
min_interval_hours=8,
|
||
max_interval_hours=24,
|
||
)
|
||
# The result should be inside active hours on the next day.
|
||
result_t = result.time().replace(second=0, microsecond=0)
|
||
assert time(9, 0) <= result_t < time(22, 0)
|
||
# The result should be on the next day.
|
||
assert result.date() > now.date()
|
||
|
||
|
||
def test_compute_next_trigger_already_past_active_hours():
|
||
"""Should defer to tomorrow when now and candidate time are past active hours."""
|
||
now = datetime(2026, 7, 1, 23, 0, 0, tzinfo=UTC)
|
||
with pytest.MonkeyPatch.context() as mp:
|
||
mp.setattr("random.randint", lambda a, b: 60) # 1h later = next-day 00:00.
|
||
result = compute_next_trigger(
|
||
now=now,
|
||
active_hours_start="09:00",
|
||
active_hours_end="22:00",
|
||
min_interval_hours=1,
|
||
max_interval_hours=2,
|
||
)
|
||
result_t = result.time().replace(second=0, microsecond=0)
|
||
assert time(9, 0) <= result_t < time(22, 0)
|
||
|
||
|
||
def test_compute_next_trigger_returns_future():
|
||
"""compute_next_trigger should always return a time after now."""
|
||
now = datetime(2026, 7, 1, 12, 0, 0, tzinfo=UTC)
|
||
for _ in range(20):
|
||
result = compute_next_trigger(
|
||
now=now,
|
||
active_hours_start="09:00",
|
||
active_hours_end="22:00",
|
||
min_interval_hours=8,
|
||
max_interval_hours=24,
|
||
)
|
||
assert result > now
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# is_in_active_hours tests
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_is_in_active_hours_true():
|
||
"""Should return True inside active hours."""
|
||
now = datetime(2026, 7, 1, 15, 30, 0, tzinfo=UTC)
|
||
assert is_in_active_hours(now, active_hours_start="09:00", active_hours_end="22:00") is True
|
||
|
||
|
||
def test_is_in_active_hours_false_before():
|
||
"""Should return False before active hours."""
|
||
now = datetime(2026, 7, 1, 8, 0, 0, tzinfo=UTC)
|
||
assert is_in_active_hours(now, active_hours_start="09:00", active_hours_end="22:00") is False
|
||
|
||
|
||
def test_is_in_active_hours_false_after():
|
||
"""Should return False after active hours."""
|
||
now = datetime(2026, 7, 1, 22, 30, 0, tzinfo=UTC)
|
||
assert is_in_active_hours(now, active_hours_start="09:00", active_hours_end="22:00") is False
|
||
|
||
|
||
def test_is_in_active_hours_with_timezone():
|
||
# 2026-07-01 01:00 UTC is 09:00 Asia/Shanghai.
|
||
now = datetime(2026, 7, 1, 1, 0, 0, tzinfo=UTC)
|
||
assert (
|
||
is_in_active_hours(
|
||
now,
|
||
active_hours_start="09:00",
|
||
active_hours_end="22:00",
|
||
timezone_name="Asia/Shanghai",
|
||
)
|
||
is True
|
||
)
|
||
|
||
|
||
def test_is_in_active_hours_boundary_start():
|
||
"""Should return True exactly at the active-window start."""
|
||
now = datetime(2026, 7, 1, 9, 0, 0, tzinfo=UTC)
|
||
assert is_in_active_hours(now, active_hours_start="09:00", active_hours_end="22:00") is True
|
||
|
||
|
||
def test_is_in_active_hours_boundary_end():
|
||
"""Should return False exactly at the active-window end."""
|
||
now = datetime(2026, 7, 1, 22, 0, 0, tzinfo=UTC)
|
||
assert is_in_active_hours(now, active_hours_start="09:00", active_hours_end="22:00") is False
|
||
|
||
|
||
def test_missed_window_waits_for_the_next_opening():
|
||
"""A gap that lands at night waits until care hours open, not two hours into them."""
|
||
now = datetime(2026, 7, 1, 23, 0, 0, tzinfo=UTC)
|
||
|
||
def randint(low: int, high: int) -> int:
|
||
# Minimum gap, then no opening jitter.
|
||
return low
|
||
|
||
with pytest.MonkeyPatch.context() as mp:
|
||
mp.setattr("random.randint", randint)
|
||
result = compute_next_trigger(
|
||
now=now,
|
||
active_hours_start="09:00",
|
||
active_hours_end="22:00",
|
||
min_interval_hours=1,
|
||
max_interval_hours=2,
|
||
)
|
||
assert result == datetime(2026, 7, 2, 9, 0, tzinfo=UTC)
|
||
|
||
|
||
def test_short_window_keeps_opening_jitter_inside():
|
||
now = datetime(2026, 7, 1, 23, 0, 0, tzinfo=UTC)
|
||
|
||
def randint(low: int, high: int) -> int:
|
||
return high
|
||
|
||
with pytest.MonkeyPatch.context() as mp:
|
||
mp.setattr("random.randint", randint)
|
||
result = compute_next_trigger(
|
||
now=now,
|
||
active_hours_start="09:00",
|
||
active_hours_end="10:00",
|
||
min_interval_hours=1,
|
||
max_interval_hours=2,
|
||
)
|
||
result_t = result.time().replace(second=0, microsecond=0)
|
||
# Jitter is capped by the 60-minute window, so 09:00 + 120 cannot escape it.
|
||
assert time(9, 0) <= result_t < time(10, 0)
|
||
|
||
|
||
def test_config_body_allows_overnight_window():
|
||
from pydantic import ValidationError
|
||
|
||
from octop.api.routers.proactive_care import ProactiveCareConfigBody
|
||
|
||
cfg = ProactiveCareConfigBody(active_hours_start="22:00", active_hours_end="08:00")
|
||
assert cfg.active_hours_start == "22:00"
|
||
with pytest.raises(ValidationError):
|
||
ProactiveCareConfigBody(active_hours_start="09:00", active_hours_end="09:00")
|
||
|
||
|
||
def test_overnight_window_accepts_evening_and_morning():
|
||
evening = datetime(2026, 7, 1, 22, 30, tzinfo=UTC)
|
||
morning = datetime(2026, 7, 2, 3, 0, tzinfo=UTC)
|
||
midday = datetime(2026, 7, 1, 12, 0, tzinfo=UTC)
|
||
assert is_in_active_hours(evening, active_hours_start="22:00", active_hours_end="08:00")
|
||
assert is_in_active_hours(morning, active_hours_start="22:00", active_hours_end="08:00")
|
||
assert not is_in_active_hours(midday, active_hours_start="22:00", active_hours_end="08:00")
|
||
assert not is_in_active_hours(
|
||
datetime(2026, 7, 2, 8, 0, tzinfo=UTC),
|
||
active_hours_start="22:00",
|
||
active_hours_end="08:00",
|
||
)
|
||
|
||
|
||
def test_overnight_gap_lands_inside_the_night_window():
|
||
now = datetime(2026, 7, 1, 12, 0, tzinfo=UTC)
|
||
with pytest.MonkeyPatch.context() as mp:
|
||
mp.setattr("random.randint", lambda a, b: 60)
|
||
result = compute_next_trigger(
|
||
now=now,
|
||
active_hours_start="22:00",
|
||
active_hours_end="08:00",
|
||
min_interval_hours=1,
|
||
max_interval_hours=2,
|
||
)
|
||
assert is_in_active_hours(result, active_hours_start="22:00", active_hours_end="08:00")
|
||
assert result > now
|
||
|
||
|
||
def _unix(moment: datetime) -> int:
|
||
return int(moment.timestamp())
|
||
|
||
|
||
def test_recent_user_activity_defers_until_idle():
|
||
now = datetime(2026, 7, 1, 15, 0, tzinfo=UTC)
|
||
last_active = now - timedelta(minutes=30)
|
||
decision = decide_send_timing(
|
||
now=now,
|
||
active_hours_start="09:00",
|
||
active_hours_end="22:00",
|
||
last_active_at=_unix(last_active),
|
||
)
|
||
assert decision.send is False
|
||
assert decision.retry_at == last_active + timedelta(hours=4)
|
||
|
||
|
||
def test_own_care_push_does_not_reset_idle():
|
||
now = datetime(2026, 7, 1, 15, 0, tzinfo=UTC)
|
||
pushed = now - timedelta(hours=6)
|
||
decision = decide_send_timing(
|
||
now=now,
|
||
active_hours_start="09:00",
|
||
active_hours_end="22:00",
|
||
last_active_at=_unix(pushed) + 60,
|
||
last_push_at=_unix(pushed),
|
||
)
|
||
assert decision.send is True
|
||
|
||
|
||
def test_reply_after_care_restarts_idle():
|
||
now = datetime(2026, 7, 1, 15, 0, tzinfo=UTC)
|
||
pushed = now - timedelta(hours=6)
|
||
replied = now - timedelta(minutes=20)
|
||
decision = decide_send_timing(
|
||
now=now,
|
||
active_hours_start="09:00",
|
||
active_hours_end="22:00",
|
||
last_active_at=_unix(replied),
|
||
last_push_at=_unix(pushed),
|
||
)
|
||
assert decision.send is False
|
||
assert decision.retry_at == replied + timedelta(hours=4)
|
||
|
||
|
||
def test_busy_turn_waits_without_leaving_the_window():
|
||
now = datetime(2026, 7, 1, 15, 0, tzinfo=UTC)
|
||
decision = decide_send_timing(
|
||
now=now,
|
||
active_hours_start="09:00",
|
||
active_hours_end="22:00",
|
||
agent_busy=True,
|
||
)
|
||
assert decision.send is False
|
||
assert decision.retry_at == now + timedelta(minutes=15)
|
||
|
||
|
||
def test_outside_hours_waits_for_the_window_to_open():
|
||
now = datetime(2026, 7, 1, 23, 0, tzinfo=UTC)
|
||
with pytest.MonkeyPatch.context() as mp:
|
||
mp.setattr("random.randint", lambda a, b: 0)
|
||
decision = decide_send_timing(
|
||
now=now,
|
||
active_hours_start="09:00",
|
||
active_hours_end="22:00",
|
||
agent_busy=True,
|
||
)
|
||
assert decision.send is False
|
||
assert decision.retry_at == datetime(2026, 7, 2, 9, 0, tzinfo=UTC)
|
||
|
||
|
||
def test_idle_deadline_after_hours_moves_to_the_next_opening():
|
||
now = datetime(2026, 7, 1, 21, 0, tzinfo=UTC)
|
||
last_active = now - timedelta(minutes=30)
|
||
with pytest.MonkeyPatch.context() as mp:
|
||
mp.setattr("random.randint", lambda a, b: 0)
|
||
decision = decide_send_timing(
|
||
now=now,
|
||
active_hours_start="09:00",
|
||
active_hours_end="22:00",
|
||
last_active_at=_unix(last_active),
|
||
)
|
||
assert decision.send is False
|
||
# Quiet at 00:30, which is outside 09:00–22:00, so wait until the window opens.
|
||
assert decision.retry_at == datetime(2026, 7, 2, 9, 0, tzinfo=UTC)
|
||
|
||
|
||
def test_compute_next_trigger_with_timezone():
|
||
now = datetime(2026, 7, 1, 1, 0, 0, tzinfo=UTC) # 09:00 Shanghai
|
||
with pytest.MonkeyPatch.context() as mp:
|
||
mp.setattr("random.randint", lambda a, b: 60) # 1 hour
|
||
result = compute_next_trigger(
|
||
now=now,
|
||
active_hours_start="09:00",
|
||
active_hours_end="22:00",
|
||
min_interval_hours=1,
|
||
max_interval_hours=2,
|
||
timezone_name="Asia/Shanghai",
|
||
)
|
||
local = result.astimezone(ZoneInfo("Asia/Shanghai"))
|
||
assert local.time().hour == 10
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# ProactiveCareConfigRepo tests
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_config_repo_get_default(agent_id: str, config_repo: ProactiveCareConfigRepo):
|
||
"""Should return defaults when no config exists."""
|
||
cfg = config_repo.get(agent_id)
|
||
assert cfg.agent_id == agent_id
|
||
assert cfg.enabled is True
|
||
assert cfg.active_hours_start == "09:00"
|
||
assert cfg.active_hours_end == "22:00"
|
||
assert cfg.min_interval_hours == 5
|
||
assert cfg.max_interval_hours == 24
|
||
|
||
|
||
def test_config_repo_upsert_and_get(agent_id: str, config_repo: ProactiveCareConfigRepo):
|
||
"""Should read the saved config after upsert."""
|
||
cfg = ProactiveCareConfig(
|
||
agent_id=agent_id,
|
||
enabled=True,
|
||
active_hours_start="10:00",
|
||
active_hours_end="20:00",
|
||
min_interval_hours=4,
|
||
max_interval_hours=12,
|
||
)
|
||
config_repo.upsert(cfg)
|
||
got = config_repo.get(agent_id)
|
||
assert got.enabled is True
|
||
assert got.active_hours_start == "10:00"
|
||
assert got.active_hours_end == "20:00"
|
||
assert got.min_interval_hours == 4
|
||
assert got.max_interval_hours == 12
|
||
|
||
|
||
def test_config_repo_upsert_update(agent_id: str, config_repo: ProactiveCareConfigRepo):
|
||
"""Repeated upsert should update the existing config."""
|
||
cfg1 = ProactiveCareConfig(agent_id=agent_id, enabled=True)
|
||
config_repo.upsert(cfg1)
|
||
cfg2 = ProactiveCareConfig(agent_id=agent_id, enabled=False, min_interval_hours=12)
|
||
config_repo.upsert(cfg2)
|
||
got = config_repo.get(agent_id)
|
||
assert got.enabled is False
|
||
assert got.min_interval_hours == 12
|
||
|
||
|
||
def test_config_repo_list_enabled(db: SqlitePool, config_repo: ProactiveCareConfigRepo):
|
||
"""list_enabled should return only enabled configs."""
|
||
uid = UserRepo(db).create(username="bob", password_hash="h", role="admin")
|
||
aid1 = new_ulid()
|
||
aid2 = new_ulid()
|
||
AgentRepo(db).create(agent_id=aid1, user_id=uid, name="bot1")
|
||
AgentRepo(db).create(agent_id=aid2, user_id=uid, name="bot2")
|
||
|
||
config_repo.upsert(ProactiveCareConfig(agent_id=aid1, enabled=True))
|
||
config_repo.upsert(ProactiveCareConfig(agent_id=aid2, enabled=False))
|
||
|
||
enabled = config_repo.list_enabled()
|
||
enabled_ids = {c.agent_id for c in enabled}
|
||
assert aid1 in enabled_ids
|
||
assert aid2 not in enabled_ids
|
||
|
||
|
||
def test_config_repo_list_enabled_includes_default_on_agents(
|
||
db: SqlitePool, config_repo: ProactiveCareConfigRepo
|
||
):
|
||
"""Agents with no config row default to enabled and must be scheduled."""
|
||
uid = UserRepo(db).create(username="carol", password_hash="h", role="admin")
|
||
aid_default = new_ulid()
|
||
aid_off = new_ulid()
|
||
AgentRepo(db).create(agent_id=aid_default, user_id=uid, name="default-on")
|
||
AgentRepo(db).create(agent_id=aid_off, user_id=uid, name="opted-out")
|
||
config_repo.upsert(ProactiveCareConfig(agent_id=aid_off, enabled=False))
|
||
|
||
enabled = config_repo.list_enabled()
|
||
enabled_ids = {c.agent_id for c in enabled}
|
||
assert aid_default in enabled_ids
|
||
assert aid_off not in enabled_ids
|
||
default_cfg = next(c for c in enabled if c.agent_id == aid_default)
|
||
assert default_cfg.enabled is True
|
||
assert default_cfg.min_interval_hours == 5
|
||
assert default_cfg.max_interval_hours == 24
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# ProactiveCareScheduler tests
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_scheduler_cancel_stops_task(
|
||
agent_id: str,
|
||
config_repo: ProactiveCareConfigRepo,
|
||
db: SqlitePool,
|
||
):
|
||
"""cancel should stop the scheduled task."""
|
||
from octop.infra.db.repos.sessions import SessionRepo
|
||
|
||
care_service = AsyncMock()
|
||
session_repo = SessionRepo(db)
|
||
|
||
# Enable the config.
|
||
config_repo.upsert(ProactiveCareConfig(agent_id=agent_id, enabled=True))
|
||
|
||
scheduler = ProactiveCareScheduler(
|
||
care_service=care_service,
|
||
config_repo=config_repo,
|
||
session_repo=session_repo,
|
||
)
|
||
|
||
# Schedule manually.
|
||
scheduler._schedule(agent_id)
|
||
assert agent_id in scheduler._tasks
|
||
|
||
# Cancel.
|
||
scheduler.cancel(agent_id)
|
||
# Wait for the task to be cancelled.
|
||
await asyncio.sleep(0.01)
|
||
assert agent_id not in scheduler._tasks
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_scheduler_reschedule_disabled(
|
||
agent_id: str,
|
||
config_repo: ProactiveCareConfigRepo,
|
||
db: SqlitePool,
|
||
):
|
||
"""reschedule should cancel the task when enabled=false."""
|
||
from octop.infra.db.repos.sessions import SessionRepo
|
||
|
||
care_service = AsyncMock()
|
||
session_repo = SessionRepo(db)
|
||
|
||
# Enable first.
|
||
config_repo.upsert(ProactiveCareConfig(agent_id=agent_id, enabled=True))
|
||
scheduler = ProactiveCareScheduler(
|
||
care_service=care_service,
|
||
config_repo=config_repo,
|
||
session_repo=session_repo,
|
||
)
|
||
scheduler._schedule(agent_id)
|
||
assert agent_id in scheduler._tasks
|
||
|
||
# Disable, then reschedule.
|
||
config_repo.upsert(ProactiveCareConfig(agent_id=agent_id, enabled=False))
|
||
scheduler.reschedule(agent_id)
|
||
await asyncio.sleep(0.01)
|
||
assert agent_id not in scheduler._tasks
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_scheduler_start_all_no_enabled(
|
||
config_repo: ProactiveCareConfigRepo,
|
||
db: SqlitePool,
|
||
):
|
||
"""start_all should not create tasks when no agent is enabled."""
|
||
from octop.infra.db.repos.sessions import SessionRepo
|
||
|
||
care_service = AsyncMock()
|
||
session_repo = SessionRepo(db)
|
||
scheduler = ProactiveCareScheduler(
|
||
care_service=care_service,
|
||
config_repo=config_repo,
|
||
session_repo=session_repo,
|
||
)
|
||
await scheduler.start_all()
|
||
assert len(scheduler._tasks) == 0
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_scheduler_start_all_includes_default_on_agent(
|
||
agent_id: str,
|
||
config_repo: ProactiveCareConfigRepo,
|
||
db: SqlitePool,
|
||
):
|
||
"""start_all should schedule agents that never saved a proactive-care row."""
|
||
from octop.infra.db.repos.sessions import SessionRepo
|
||
|
||
care_service = AsyncMock()
|
||
scheduler = ProactiveCareScheduler(
|
||
care_service=care_service,
|
||
config_repo=config_repo,
|
||
session_repo=SessionRepo(db),
|
||
)
|
||
await scheduler.start_all()
|
||
assert agent_id in scheduler._tasks
|
||
scheduler.cancel(agent_id)
|
||
await asyncio.sleep(0.01)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_shutdown_cancels_long_sleep_quickly(
|
||
agent_id: str,
|
||
config_repo: ProactiveCareConfigRepo,
|
||
db: SqlitePool,
|
||
) -> None:
|
||
"""A default-on loop sleeps for hours; shutdown must not wait that out."""
|
||
from octop.infra.db.repos.sessions import SessionRepo
|
||
|
||
scheduler = ProactiveCareScheduler(
|
||
care_service=AsyncMock(),
|
||
config_repo=config_repo,
|
||
session_repo=SessionRepo(db),
|
||
)
|
||
scheduler.ensure_scheduled(agent_id)
|
||
assert agent_id in scheduler._tasks
|
||
await asyncio.wait_for(scheduler.shutdown(), timeout=2)
|
||
assert scheduler._tasks == {}
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_suspend_skips_new_schedules(
|
||
agent_id: str,
|
||
config_repo: ProactiveCareConfigRepo,
|
||
db: SqlitePool,
|
||
) -> None:
|
||
from octop.infra.db.repos.sessions import SessionRepo
|
||
|
||
scheduler = ProactiveCareScheduler(
|
||
care_service=AsyncMock(),
|
||
config_repo=config_repo,
|
||
session_repo=SessionRepo(db),
|
||
)
|
||
scheduler.suspend()
|
||
scheduler.ensure_scheduled(agent_id)
|
||
await scheduler.start_all()
|
||
assert scheduler._tasks == {}
|