Skip to content

Commit f28df52

Browse files
committed
tests(services): add full scheduler and watcher coverage
Add dedicated service test suites for scheduler and watcher behavior to close remaining high-value coverage gaps in background orchestration code. Scheduler coverage: - verify singleton initialization and lifecycle guards for start/stop - cover DB-backed setting lookup including fallback and guaranteed session close - exercise dynamic job rescheduling including disabled interval handling - cover interval-to-trigger mapping branches (daily/weekly/monthly/fallback) - validate backup, cleanup, and scan job wrappers across success and failure paths Watcher coverage: - cover event handler stop/trigger behavior and timer batching window logic - verify event filtering for directories, temp/system files, and ignored paths - test watcher singleton/init guard and start/stop idempotency - exercise refresh_watches add/remove/keep/error paths and session close behavior Validation: - targeted suites pass for scheduler/watcher - full test suite passes: 343 passed, 1 skipped - overall coverage improved to 88%
1 parent 37c1ecf commit f28df52

2 files changed

Lines changed: 454 additions & 0 deletions

File tree

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
from types import SimpleNamespace
2+
from unittest.mock import MagicMock
3+
4+
import app.services.scheduler as scheduler
5+
from app.models.setting import SystemSetting
6+
7+
8+
def _service():
9+
svc = object.__new__(scheduler.SchedulerService)
10+
svc._scheduler = MagicMock()
11+
return svc
12+
13+
14+
def _session_local_factory(db):
15+
bind = db.get_bind()
16+
17+
def _factory():
18+
return db.__class__(bind=bind)
19+
20+
return _factory
21+
22+
23+
def test_scheduler_singleton_new_initializes_once(monkeypatch):
24+
monkeypatch.setattr(scheduler.SchedulerService, "_instance", None)
25+
monkeypatch.setattr(scheduler.SchedulerService, "_scheduler", None)
26+
27+
sched_obj = MagicMock()
28+
bg_ctor = MagicMock(return_value=sched_obj)
29+
monkeypatch.setattr(scheduler, "BackgroundScheduler", bg_ctor)
30+
31+
first = scheduler.SchedulerService()
32+
second = scheduler.SchedulerService()
33+
34+
assert first is second
35+
assert scheduler.SchedulerService._scheduler is sched_obj
36+
bg_ctor.assert_called_once()
37+
38+
39+
def test_start_only_runs_when_not_running():
40+
svc = _service()
41+
svc._scheduler.running = False
42+
svc._scheduler.start.side_effect = lambda: setattr(svc._scheduler, "running", True)
43+
svc.reschedule_jobs = MagicMock()
44+
45+
svc.start()
46+
svc.start()
47+
48+
svc._scheduler.start.assert_called_once()
49+
svc.reschedule_jobs.assert_called_once()
50+
51+
52+
def test_stop_only_runs_when_running():
53+
svc = _service()
54+
svc._scheduler.running = True
55+
56+
svc.stop()
57+
svc._scheduler.running = False
58+
svc.stop()
59+
60+
svc._scheduler.shutdown.assert_called_once()
61+
62+
63+
def test_get_setting_value_reads_existing_and_falls_back(monkeypatch, db):
64+
svc = _service()
65+
monkeypatch.setattr(scheduler, "SessionLocal", _session_local_factory(db))
66+
67+
db.add(
68+
SystemSetting(
69+
key="system.task.scan.interval",
70+
value="daily",
71+
category="system",
72+
data_type="string",
73+
label="Scan",
74+
)
75+
)
76+
db.commit()
77+
78+
assert svc._get_setting_value("system.task.scan.interval", "weekly") == "daily"
79+
assert svc._get_setting_value("system.task.cleanup.interval", "monthly") == "monthly"
80+
81+
82+
def test_get_setting_value_always_closes_session(monkeypatch):
83+
svc = _service()
84+
fake_db = MagicMock()
85+
q = fake_db.query.return_value
86+
q.filter.return_value = q
87+
q.first.return_value = None
88+
monkeypatch.setattr(scheduler, "SessionLocal", lambda: fake_db)
89+
90+
assert svc._get_setting_value("missing", "default") == "default"
91+
fake_db.close.assert_called_once()
92+
93+
94+
def test_reschedule_jobs_removes_existing_and_skips_disabled(monkeypatch):
95+
svc = _service()
96+
svc._scheduler = MagicMock()
97+
98+
monkeypatch.setattr(
99+
scheduler.SchedulerService,
100+
"_TASK_REGISTRY",
101+
{
102+
"scan": {
103+
"func": "run_scan_job",
104+
"default_interval": "daily",
105+
"default_hour": 4,
106+
"description": "Library Scan",
107+
},
108+
"cleanup": {
109+
"func": "run_cleanup_job",
110+
"default_interval": "monthly",
111+
"default_hour": 3,
112+
"description": "Cleanup",
113+
},
114+
},
115+
)
116+
117+
lookup = {
118+
"system.task.scan.interval": "daily",
119+
"system.task.cleanup.interval": "disabled",
120+
}
121+
svc._get_setting_value = MagicMock(side_effect=lambda key, default: lookup[key])
122+
svc._get_trigger_for_interval = MagicMock(side_effect=lambda interval, hour: f"{interval}-{hour}")
123+
124+
svc.reschedule_jobs()
125+
126+
svc._scheduler.remove_all_jobs.assert_called_once()
127+
svc._scheduler.add_job.assert_called_once()
128+
kwargs = svc._scheduler.add_job.call_args.kwargs
129+
assert kwargs["id"] == "scan"
130+
assert kwargs["trigger"] == "daily-4"
131+
assert kwargs["replace_existing"] is True
132+
133+
134+
def test_get_trigger_for_interval_covers_all_mappings():
135+
daily = scheduler.SchedulerService._get_trigger_for_interval("daily", 1)
136+
weekly = scheduler.SchedulerService._get_trigger_for_interval("weekly", 2)
137+
monthly = scheduler.SchedulerService._get_trigger_for_interval("monthly", 3)
138+
fallback = scheduler.SchedulerService._get_trigger_for_interval("nonsense", 4)
139+
140+
assert "hour='1'" in str(daily)
141+
assert "day_of_week='mon'" in str(weekly)
142+
assert "day='1'" in str(monthly)
143+
assert "day_of_week='mon'" in str(fallback)
144+
145+
146+
def test_run_backup_job_success_and_failure(monkeypatch):
147+
logger = MagicMock()
148+
monkeypatch.setattr(scheduler, "logger", logger)
149+
150+
monkeypatch.setattr(
151+
scheduler.BackupService,
152+
"create_backup",
153+
MagicMock(return_value={"filename": "backup.zip"}),
154+
)
155+
scheduler.SchedulerService.run_backup_job()
156+
logger.info.assert_any_call("Backup Complete: backup.zip")
157+
158+
monkeypatch.setattr(
159+
scheduler.BackupService,
160+
"create_backup",
161+
MagicMock(side_effect=RuntimeError("backup broke")),
162+
)
163+
scheduler.SchedulerService.run_backup_job()
164+
logger.error.assert_called_with("Backup Failed: backup broke")
165+
166+
167+
def test_run_cleanup_job_queued_skipped_and_exception(monkeypatch):
168+
logger = MagicMock()
169+
monkeypatch.setattr(scheduler, "logger", logger)
170+
171+
add_cleanup = MagicMock(
172+
side_effect=[
173+
{"status": "queued", "job_id": 5},
174+
{"status": "ignored", "message": "already running"},
175+
RuntimeError("queue failed"),
176+
]
177+
)
178+
monkeypatch.setattr(scheduler.scan_manager, "add_cleanup_task", add_cleanup)
179+
180+
scheduler.SchedulerService.run_cleanup_job()
181+
scheduler.SchedulerService.run_cleanup_job()
182+
scheduler.SchedulerService.run_cleanup_job()
183+
184+
logger.info.assert_any_call("Cleanup Job Queued: ID 5")
185+
logger.info.assert_any_call("Cleanup Skipped: already running")
186+
logger.error.assert_called_with("Failed to queue cleanup: queue failed")
187+
188+
189+
def test_run_scan_job_handles_empty_success_and_error(monkeypatch):
190+
logger = MagicMock()
191+
monkeypatch.setattr(scheduler, "logger", logger)
192+
add_task = MagicMock()
193+
monkeypatch.setattr(scheduler.scan_manager, "add_task", add_task)
194+
195+
db_empty = MagicMock()
196+
q_empty = db_empty.query.return_value
197+
q_empty.all.return_value = []
198+
199+
libraries = [SimpleNamespace(id=1, name="A"), SimpleNamespace(id=2, name="B")]
200+
db_with_libs = MagicMock()
201+
q_libs = db_with_libs.query.return_value
202+
q_libs.all.return_value = libraries
203+
204+
db_error = MagicMock()
205+
db_error.query.side_effect = RuntimeError("query failed")
206+
207+
session_local = MagicMock(side_effect=[db_empty, db_with_libs, db_error])
208+
monkeypatch.setattr(scheduler, "SessionLocal", session_local)
209+
210+
scheduler.SchedulerService.run_scan_job()
211+
scheduler.SchedulerService.run_scan_job()
212+
scheduler.SchedulerService.run_scan_job()
213+
214+
add_task.assert_any_call(1, force=False)
215+
add_task.assert_any_call(2, force=False)
216+
assert add_task.call_count == 2
217+
db_empty.close.assert_called_once()
218+
db_with_libs.close.assert_called_once()
219+
db_error.close.assert_called_once()
220+
logger.error.assert_called_with("Scheduled Scan Failed: query failed")

0 commit comments

Comments
 (0)