"""Daemon tests with a fake scheduler (captures add_job) and a fake clock.""" from __future__ import annotations import signal import threading import time from datetime import date, datetime, timedelta from zoneinfo import ZoneInfo import pytest from _orchestration_stubs import FakeClock, FakeDeliverer, FakeStore from apscheduler.events import EVENT_JOB_MISSED, JobExecutionEvent from apscheduler.schedulers import SchedulerNotRunningError from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.date import DateTrigger from apscheduler.triggers.interval import IntervalTrigger from us2kr import daemon as daemon_mod from us2kr.config import Settings from us2kr.daemon import ( BRIEFING_JOB_ID, HEARTBEAT_JOB_ID, PREMARKET_JOB_ID, RECOVERY_JOB_ID, Daemon, _parse_hhmm, ) KST = ZoneInfo("Asia/Seoul") NOW = datetime(2026, 8, 25, 12, 0, tzinfo=KST) # Tuesday noon KST -> next briefing Wed 06:15 KST class FakeScheduler: def __init__(self): self.jobs: dict[str, dict] = {} self.add_calls: list[dict] = [] self.shutdown_calls: list[bool] = [] self.listeners: list[tuple[object, int | None]] = [] def add_listener(self, callback, mask=None): self.listeners.append((callback, mask)) def add_job(self, func, trigger=None, **kwargs): entry = {"func": func, "trigger": trigger, **kwargs} self.add_calls.append(entry) self.jobs[kwargs.get("id", func.__name__)] = entry return entry def shutdown(self, wait=True): self.shutdown_calls.append(wait) def make_settings(tmp_path, **overrides) -> Settings: values = {"US2KR_DATA_DIR": str(tmp_path / "data"), "US2KR_LOG_DIR": str(tmp_path / "logs")} values.update(overrides) return Settings(_env_file=None, **values) class RunRecorder: def __init__(self, fail: bool = False): self.sessions: list[date] = [] self.fail = fail def __call__(self, session: date): self.sessions.append(session) if self.fail: raise RuntimeError("pipeline exploded") def make_daemon(tmp_path, *, clock=None, store=None, run_fn=None, settings=None, deliverer=None): scheduler = FakeScheduler() clock = clock or FakeClock() store = store or FakeStore() run_fn = run_fn or RunRecorder() deliverer = deliverer or FakeDeliverer("file") d = Daemon( settings or make_settings(tmp_path), scheduler=scheduler, clock=clock, store=store, run_fn=run_fn, now_fn=lambda: NOW, deliverers_fn=lambda s: [deliverer], ) return d, scheduler, clock, store, run_fn, deliverer # -------------------------------------------------------------------------------- scheduling def test_schedule_next_schedules_one_shot_job_at_next_briefing_time(tmp_path): d, scheduler, *_ = make_daemon(tmp_path) expected_run_at = datetime(2026, 8, 26, 6, 15, tzinfo=KST) # Tue 16:00 EDT + 75 min session, run_at = d.schedule_next() assert session == date(2026, 8, 25) and run_at == expected_run_at assert d.next_run == (session, run_at) job = scheduler.jobs[BRIEFING_JOB_ID] assert isinstance(job["trigger"], DateTrigger) assert job["trigger"].run_date == expected_run_at assert job["args"] == [session, run_at] assert job["replace_existing"] is True # None: a late (sleep/wake) job must still FIRE so it can reschedule itself; see BRIEFING_MISFIRE_GRACE assert job["misfire_grace_time"] is None def test_schedule_next_uses_clock_next_briefing_verbatim(tmp_path): run_at = datetime(2026, 11, 28, 4, 15, tzinfo=KST) # early close day example clock = FakeClock(next_run=(date(2026, 11, 27), run_at)) d, scheduler, *_ = make_daemon(tmp_path, clock=clock) d.schedule_next() assert scheduler.jobs[BRIEFING_JOB_ID]["trigger"].run_date == run_at assert clock.calls[-1] == ("next_briefing", NOW) def test_schedule_next_survives_calendar_error(tmp_path): class BrokenClock(FakeClock): def next_briefing(self, now): raise RuntimeError("calendar unavailable") d, scheduler, *_ = make_daemon(tmp_path, clock=BrokenClock()) assert d.schedule_next() is None assert BRIEFING_JOB_ID not in scheduler.jobs # heartbeat retries scheduling d.clock = FakeClock() d._heartbeat_job() assert BRIEFING_JOB_ID in scheduler.jobs def test_start_schedules_heartbeat_and_no_premarket_by_default(tmp_path): d, scheduler, *_ = make_daemon(tmp_path) d.start() assert set(scheduler.jobs) == {BRIEFING_JOB_ID, HEARTBEAT_JOB_ID} hb = scheduler.jobs[HEARTBEAT_JOB_ID] assert isinstance(hb["trigger"], IntervalTrigger) assert hb["trigger"].interval.total_seconds() == 3600 d._heartbeat_job() # must not raise def test_premarket_job_scheduled_when_enabled(tmp_path, caplog): settings = make_settings( tmp_path, US2KR_PREMARKET_UPDATE_ENABLED="true", US2KR_PREMARKET_TIME_KST="08:20" ) d, scheduler, *_ = make_daemon(tmp_path, settings=settings) d.start() job = scheduler.jobs[PREMARKET_JOB_ID] trig = job["trigger"] assert isinstance(trig, CronTrigger) fields = {f.name: str(f) for f in trig.fields} assert fields["hour"] == "8" and fields["minute"] == "20" and fields["day_of_week"] == "mon-fri" assert str(trig.timezone) == "Asia/Seoul" with caplog.at_level("INFO", logger="us2kr.daemon"): job["func"]() assert "premarket update not yet implemented" in caplog.text def test_premarket_extension_point_is_called_when_provided(tmp_path): calls = [] d, *_ = make_daemon(tmp_path) d.premarket_fn = lambda: calls.append("ran") d._premarket_job() assert calls == ["ran"] @pytest.mark.parametrize("bad", ["8:", "25:00", "08:60", "abc", "0820"]) def test_parse_hhmm_rejects_invalid(bad): with pytest.raises(ValueError): _parse_hhmm(bad) def test_parse_hhmm_ok(): assert _parse_hhmm("08:20") == (8, 20) assert _parse_hhmm(" 7:05 ") == (7, 5) # ---------------------------------------------------------------------------------- catch-up def test_catchup_runs_when_candidate_and_not_yet_reported(tmp_path): clock = FakeClock(catchup=date(2026, 8, 24)) d, _scheduler, clock, _store, run_fn, _ = make_daemon(tmp_path, clock=clock) assert d.run_catchup() is True assert run_fn.sessions == [date(2026, 8, 24)] assert clock.calls[0] == ("catchup_candidate", (NOW, 3)) def test_catchup_skipped_when_report_exists(tmp_path): clock = FakeClock(catchup=date(2026, 8, 24)) store = FakeStore() store.reports[(date(2026, 8, 25), "briefing")] = 1 # krx_date_for(2026-08-24) == 2026-08-25 d, _scheduler, clock, store, run_fn, _ = make_daemon(tmp_path, clock=clock, store=store) assert d.run_catchup() is False assert run_fn.sessions == [] def test_catchup_skipped_without_candidate(tmp_path): d, _scheduler, _clock, _store, run_fn, _ = make_daemon(tmp_path) assert d.run_catchup() is False assert run_fn.sessions == [] def test_catchup_failure_is_logged_and_start_continues(tmp_path): clock = FakeClock(catchup=date(2026, 8, 24)) d, scheduler, clock, _store, run_fn, deliverer = make_daemon( tmp_path, clock=clock, run_fn=RunRecorder(fail=True) ) d.start() # must not raise assert run_fn.sessions == [date(2026, 8, 24)] assert BRIEFING_JOB_ID in scheduler.jobs assert deliverer.errors and "catch-up" in deliverer.errors[0][0] def test_start_uses_catchup_window_from_settings(tmp_path): settings = make_settings(tmp_path, US2KR_CATCHUP_WINDOW_HOURS="5") d, _scheduler, clock, *_ = make_daemon(tmp_path, settings=settings) d.start() assert clock.calls[0] == ("catchup_candidate", (NOW, 5)) # -------------------------------------------------------------------------------------- job def test_job_runs_pipeline_and_reschedules(tmp_path): d, scheduler, clock, _store, run_fn, deliverer = make_daemon(tmp_path) d.schedule_next() job = scheduler.jobs[BRIEFING_JOB_ID] session = job["args"][0] clock.next_run = (date(2026, 8, 26), datetime(2026, 8, 27, 6, 15, tzinfo=KST)) job["func"](*job["args"]) assert run_fn.sessions == [session] assert len(scheduler.add_calls) == 2 assert scheduler.jobs[BRIEFING_JOB_ID]["args"] == [date(2026, 8, 26), clock.next_run[1]] assert d.next_run == clock.next_run assert deliverer.errors == [] def test_job_exception_notifies_and_still_reschedules(tmp_path): d, scheduler, clock, _store, run_fn, deliverer = make_daemon(tmp_path, run_fn=RunRecorder(fail=True)) d.schedule_next() job = scheduler.jobs[BRIEFING_JOB_ID] clock.next_run = (date(2026, 8, 26), datetime(2026, 8, 27, 6, 15, tzinfo=KST)) job["func"](*job["args"]) # must not raise assert run_fn.sessions == [date(2026, 8, 25)] assert scheduler.jobs[BRIEFING_JOB_ID]["trigger"].run_date == clock.next_run[1] assert len(deliverer.errors) == 1 subject, detail = deliverer.errors[0] assert "브리핑 실패" in subject and "pipeline exploded" in detail def test_notify_error_failures_are_swallowed(tmp_path): class Exploding(FakeDeliverer): def notify_error(self, subject, detail): raise RuntimeError("telegram down") d, *_ = make_daemon(tmp_path, run_fn=RunRecorder(fail=True), deliverer=Exploding("tg")) d._briefing_job(date(2026, 8, 25)) # must not raise d.deliverers_fn = lambda s: (_ for _ in ()).throw(RuntimeError("cannot build")) d._briefing_job(date(2026, 8, 25)) # must not raise either # --------------------------------------------------------------------------------- shutdown def test_signal_handler_shuts_scheduler_down(tmp_path): d, scheduler, *_ = make_daemon(tmp_path) old_term, old_int = signal.getsignal(signal.SIGTERM), signal.getsignal(signal.SIGINT) try: d.install_signal_handlers() handler = signal.getsignal(signal.SIGTERM) assert callable(handler) and handler is not old_term handler(signal.SIGTERM, None) finally: signal.signal(signal.SIGTERM, old_term) signal.signal(signal.SIGINT, old_int) assert scheduler.shutdown_calls == [False] def test_shutdown_is_idempotent(tmp_path): class Once(FakeScheduler): def shutdown(self, wait=True): super().shutdown(wait) if len(self.shutdown_calls) > 1: raise RuntimeError("not running") d, *_ = make_daemon(tmp_path) d.scheduler = Once() d.shutdown() d.shutdown() # second call raises inside scheduler but is swallowed # ------------------------------------------------------------------------------- run_daemon def test_run_daemon_wires_real_parts_and_blocks_on_scheduler(monkeypatch, tmp_path): """run_daemon builds Daemon with a BlockingScheduler; we replace start() so the test does not block.""" started = {} fake_clock = FakeClock() fake_store = FakeStore() class FakeBlocking(FakeScheduler): def __init__(self, timezone=None): super().__init__() started["timezone"] = timezone def start(self, paused=False): started["started"] = True raise KeyboardInterrupt # simulate Ctrl-C ending the blocking loop monkeypatch.setattr(daemon_mod, "BlockingScheduler", FakeBlocking) monkeypatch.setattr(daemon_mod, "MarketClock", lambda offset, **_kw: fake_clock) monkeypatch.setattr(daemon_mod, "Store", lambda path: fake_store) monkeypatch.setattr(daemon_mod.signal, "signal", lambda *a, **k: None) daemon_mod.run_daemon(make_settings(tmp_path)) assert started["started"] is True assert str(started["timezone"]) == "Asia/Seoul" assert fake_store.closed is True # ------------------------------------------------------------------- missed / late briefing job def test_start_registers_missed_job_listener(tmp_path): d, scheduler, *_ = make_daemon(tmp_path) d.start() assert (d._on_job_missed, EVENT_JOB_MISSED) in scheduler.listeners @pytest.mark.parametrize(("lateness_h", "expect_run"), [(0, True), (2, True), (3, False), (5, False)]) def test_late_briefing_job_runs_inside_window_and_skips_outside(tmp_path, lateness_h, expect_run): """With misfire grace None the job fires late after sleep/wake; it must apply the catch-up window (default 3h) and reschedule in every case.""" d, scheduler, clock, _store, run_fn, deliverer = make_daemon(tmp_path) run_at = NOW - timedelta(hours=lateness_h) clock.next_run = (date(2026, 8, 26), datetime(2026, 8, 27, 6, 15, tzinfo=KST)) d._briefing_job(date(2026, 8, 25), run_at) assert (run_fn.sessions == [date(2026, 8, 25)]) is expect_run assert scheduler.jobs[BRIEFING_JOB_ID]["trigger"].run_date == clock.next_run[1] # always rescheduled assert d.next_run == clock.next_run assert deliverer.errors == [] def test_heartbeat_recovers_when_briefing_job_was_dropped(tmp_path): """Scheduler lost the one-shot job: heartbeat must catch up (inside window) and re-arm the schedule.""" now = {"t": NOW} d, scheduler, clock, _store, run_fn, _ = make_daemon(tmp_path) d.now_fn = lambda: now["t"] session, run_at = d.schedule_next() del scheduler.jobs[BRIEFING_JOB_ID] # what APScheduler does when it drops a misfired DateTrigger job now["t"] = run_at + timedelta(hours=1, minutes=45) clock.catchup = session # still inside the 3h catch-up window clock.next_run = (date(2026, 8, 26), datetime(2026, 8, 27, 6, 15, tzinfo=KST)) d._heartbeat_job() assert run_fn.sessions == [session] job = scheduler.jobs[BRIEFING_JOB_ID] assert job["trigger"].run_date == clock.next_run[1] and job["trigger"].run_date > now["t"] assert d.next_run == clock.next_run def test_heartbeat_reschedules_stale_next_run_without_running_outside_window(tmp_path): now = {"t": NOW} d, scheduler, clock, _store, run_fn, _ = make_daemon(tmp_path) d.now_fn = lambda: now["t"] _session, run_at = d.schedule_next() del scheduler.jobs[BRIEFING_JOB_ID] now["t"] = run_at + timedelta(hours=4) # clock.catchup stays None: outside the window clock.next_run = (date(2026, 8, 26), datetime(2026, 8, 27, 6, 15, tzinfo=KST)) d._heartbeat_job() assert run_fn.sessions == [] assert scheduler.jobs[BRIEFING_JOB_ID]["trigger"].run_date == clock.next_run[1] assert d.next_run == clock.next_run def test_heartbeat_leaves_schedule_alone_when_briefing_is_due_or_barely_late(tmp_path): """A run time in the last few minutes means the job is running or about to: do not touch it.""" now = {"t": NOW} d, scheduler, _clock, _store, run_fn, _ = make_daemon(tmp_path) d.now_fn = lambda: now["t"] _session, run_at = d.schedule_next() for delta in (timedelta(minutes=-30), timedelta(0), timedelta(minutes=2)): now["t"] = run_at + delta d._heartbeat_job() assert len(scheduler.add_calls) == 1 and run_fn.sessions == [] def test_heartbeat_does_not_run_catchup_while_briefing_in_progress(tmp_path): now = {"t": NOW} d, scheduler, clock, _store, run_fn, _ = make_daemon(tmp_path) d.now_fn = lambda: now["t"] session, run_at = d.schedule_next() now["t"] = run_at + timedelta(hours=1) clock.catchup = session clock.next_run = (date(2026, 8, 26), datetime(2026, 8, 27, 6, 15, tzinfo=KST)) with d._run_lock: # a briefing is running on another executor thread d._heartbeat_job() assert run_fn.sessions == [] # no concurrent second run assert scheduler.jobs[BRIEFING_JOB_ID]["trigger"].run_date == clock.next_run[1] # but re-armed def test_on_job_missed_queues_recovery_under_a_different_id(tmp_path): """Re-adding 'briefing' from the listener would race APScheduler's own remove_job of the exhausted one-shot job; recovery must go through a separately named job.""" d, scheduler, clock, _store, run_fn, _ = make_daemon(tmp_path) d.start() session, run_at = d.next_run del scheduler.jobs[BRIEFING_JOB_ID] clock.catchup = session clock.next_run = (date(2026, 8, 26), datetime(2026, 8, 27, 6, 15, tzinfo=KST)) d._on_job_missed(JobExecutionEvent(EVENT_JOB_MISSED, BRIEFING_JOB_ID, "default", run_at)) assert scheduler.add_calls[-1]["id"] == RECOVERY_JOB_ID assert BRIEFING_JOB_ID not in scheduler.jobs and run_fn.sessions == [] recovery = scheduler.jobs[RECOVERY_JOB_ID] assert isinstance(recovery["trigger"], DateTrigger) and recovery["misfire_grace_time"] is None recovery["func"]() assert run_fn.sessions == [session] assert scheduler.jobs[BRIEFING_JOB_ID]["args"] == [date(2026, 8, 26), clock.next_run[1]] n_calls = len(scheduler.add_calls) d._on_job_missed(JobExecutionEvent(EVENT_JOB_MISSED, HEARTBEAT_JOB_ID, "default", run_at)) # ignored assert len(scheduler.add_calls) == n_calls def test_recover_schedule_survives_catchup_error(tmp_path): class BrokenCatchup(FakeClock): def catchup_candidate(self, now, window_hours): raise RuntimeError("calendar unavailable") d, scheduler, *_ = make_daemon(tmp_path, clock=BrokenCatchup()) d._recover_schedule() # must not raise assert BRIEFING_JOB_ID in scheduler.jobs class LateThenNormalClock(FakeClock): """next_briefing answers a queue of (session, run_at) pairs; the last answer repeats.""" def __init__(self, answers): super().__init__() self.answers = list(answers) def next_briefing(self, now): self.calls.append(("next_briefing", now)) if len(self.answers) > 1: return self.answers.pop(0) return self.answers[0] @pytest.mark.parametrize(("lateness_h", "expect_run"), [(1, True), (4, False)]) def test_real_scheduler_fires_late_one_shot_job_and_reschedules(tmp_path, lateness_h, expect_run): """Regression for the misfire bug: a DateTrigger already in the past (machine slept) must still fire through the real APScheduler, apply the window, and leave a fresh 'briefing' job behind.""" real_now = datetime.now(KST) late_session, late_run_at = date(2026, 8, 24), real_now - timedelta(hours=lateness_h) next_session, next_run_at = date(2026, 8, 25), real_now + timedelta(hours=20) clock = LateThenNormalClock([(late_session, late_run_at), (next_session, next_run_at)]) run_fn = RunRecorder() scheduler = BackgroundScheduler(timezone=KST) d = Daemon( make_settings(tmp_path), scheduler=scheduler, clock=clock, store=FakeStore(), run_fn=run_fn, deliverers_fn=lambda s: [FakeDeliverer("file")], ) d.start() assert d.next_run == (late_session, late_run_at) scheduler.start() try: deadline = time.monotonic() + 5 while time.monotonic() < deadline and d.next_run != (next_session, next_run_at): time.sleep(0.05) assert d.next_run == (next_session, next_run_at) job = scheduler.get_job(BRIEFING_JOB_ID) assert job is not None and job.next_run_time == next_run_at assert (run_fn.sessions == [late_session]) is expect_run finally: scheduler.shutdown(wait=True) # --------------------------------------------------------------- shutdown during start-up catch-up def test_signal_during_catchup_unwinds_when_scheduler_not_running(tmp_path): class NotStarted(FakeScheduler): def shutdown(self, wait=True): super().shutdown(wait) raise SchedulerNotRunningError # real APScheduler behaviour before start() d, *_ = make_daemon(tmp_path) d.scheduler = NotStarted() old_term, old_int = signal.getsignal(signal.SIGTERM), signal.getsignal(signal.SIGINT) try: d.install_signal_handlers() handler = signal.getsignal(signal.SIGTERM) with pytest.raises(KeyboardInterrupt): handler(signal.SIGTERM, None) finally: signal.signal(signal.SIGTERM, old_term) signal.signal(signal.SIGINT, old_int) assert d.stop_requested is True assert d.scheduler.shutdown_calls == [False] def test_shutdown_during_catchup_prevents_scheduling(tmp_path): class NotStarted(FakeScheduler): def shutdown(self, wait=True): super().shutdown(wait) raise SchedulerNotRunningError holder: dict = {} def run_fn(session): # simulates the signal handler firing mid catch-up (swallowed variant) holder["d"].shutdown() clock = FakeClock(catchup=date(2026, 8, 24)) d, *_ = make_daemon(tmp_path, clock=clock, run_fn=run_fn) d.scheduler = NotStarted() holder["d"] = d d.start() assert d.stop_requested is True assert d.scheduler.jobs == {} # nothing scheduled; run_daemon skips scheduler.start() def test_run_daemon_stops_cleanly_when_signal_arrives_during_catchup(monkeypatch, tmp_path): handlers: dict = {} started: dict = {} fake_store = FakeStore() class FakeBlocking(FakeScheduler): def __init__(self, timezone=None): super().__init__() self.running = False def start(self, paused=False): started["started"] = True def shutdown(self, wait=True): super().shutdown(wait) if not self.running: raise SchedulerNotRunningError def run_briefing(session, settings): handlers[signal.SIGTERM](signal.SIGTERM, None) # SIGTERM while the catch-up briefing runs raise AssertionError("unreachable: the handler must unwind the main thread") monkeypatch.setattr(daemon_mod, "BlockingScheduler", FakeBlocking) monkeypatch.setattr(daemon_mod, "MarketClock", lambda offset, **_kw: FakeClock(catchup=date(2026, 8, 24))) monkeypatch.setattr(daemon_mod, "Store", lambda path: fake_store) monkeypatch.setattr(daemon_mod, "run_briefing", run_briefing) monkeypatch.setattr( daemon_mod.signal, "signal", lambda signum, handler: handlers.__setitem__(signum, handler) ) daemon_mod.run_daemon(make_settings(tmp_path)) # must return, not hang or raise assert "started" not in started assert fake_store.closed is True def test_run_lock_serialises_catchup_and_job(tmp_path): """Sanity: the lock used by heartbeat recovery is the one held while run_fn executes.""" seen = {} d, *_ = make_daemon(tmp_path) def run_fn(session): seen["locked"] = d._run_lock.locked() d.run_fn = run_fn d._briefing_job(date(2026, 8, 25), NOW) assert seen["locked"] is True assert isinstance(d._run_lock, type(threading.Lock()))