Skip to content

Commit ebc18b7

Browse files
committed
fix(test): prevent stats_loop tests from hanging on CI
_capture_stats_loop called stats_loop() after the with-patch block exited. With all patches removed, DBWriter.connect() attempted a real DB connection, failed, and the retry loop spun forever because mock_event.is_set() was configured to return False. Replace _capture_stats_loop with _run_stats_loop, which invokes stats_loop() inside the patch context so DBWriter stays mocked throughout the call.
1 parent fbec363 commit ebc18b7

1 file changed

Lines changed: 27 additions & 17 deletions

File tree

parser/tests/test_main.py

Lines changed: 27 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -121,8 +121,15 @@ def test_metadata_exception_is_swallowed(
121121
mock_batch.assert_called_once()
122122

123123

124-
def _capture_stats_loop(tmp_path, mock_event):
125-
"""Helper: run main() with CapturingThread + controlled stop_event, return stats_loop."""
124+
def _run_stats_loop(tmp_path, mock_event, *, configure_db=None):
125+
"""Run main(), then invoke the captured stats_loop closure under the same patches.
126+
127+
Calling stats_loop() inside the with-block is critical: if called after
128+
the block exits, DBWriter and Config are unpatched, connect() fails against
129+
a real DB, and the retry loop hangs forever.
130+
131+
Returns the MagicMock instance used as every DBWriter in the test.
132+
"""
126133
captured = {}
127134

128135
class CapturingThread(threading.Thread):
@@ -134,15 +141,18 @@ def __init__(self, *args, target=None, name=None, **kwargs):
134141
def start(self):
135142
pass # don't spawn real threads in unit tests
136143

144+
mock_db = MagicMock()
145+
137146
with patch("parser.main.threading.Thread", CapturingThread), \
138147
patch("parser.main.threading.Event", return_value=mock_event), \
139148
patch("parser.main.FileManager"), \
140149
patch("parser.main.FileWatcher"), \
141-
patch("parser.main.DBWriter") as MockDBWriter, \
150+
patch("parser.main.DBWriter", return_value=mock_db), \
142151
patch("parser.main.Config.PARSER_ENABLED", True), \
143152
patch("parser.main.Config.PROCESS_EXISTING_ON_STARTUP", False), \
144153
patch("parser.main.Config.DATABASE_URL", "postgresql://test"), \
145154
patch("parser.main.Config.USER_ID", "test_user"), \
155+
patch("parser.main.Config.STATS_REFRESH_INTERVAL", 60), \
146156
patch("parser.main.Config.INCOMING_DIR", tmp_path), \
147157
patch("parser.main.Config.ARCHIVED_DIR", tmp_path), \
148158
patch("parser.main.Config.QUARANTINE_DIR", tmp_path), \
@@ -151,8 +161,12 @@ def start(self):
151161
main()
152162
except (KeyboardInterrupt, SystemExit):
153163
pass
164+
if configure_db is not None:
165+
configure_db(mock_db)
166+
if "stats_loop" in captured:
167+
captured["stats_loop"]()
154168

155-
return captured.get("stats_loop"), MockDBWriter
169+
return mock_db
156170

157171

158172
def test_stats_loop_calls_refresh_windowed_stats_on_startup(tmp_path):
@@ -161,12 +175,10 @@ def test_stats_loop_calls_refresh_windowed_stats_on_startup(tmp_path):
161175
mock_event.is_set.return_value = False # connect loop: enter → connect succeeds → break
162176
mock_event.wait.return_value = True # timer wait → loop exits immediately
163177

164-
stats_loop, MockDBWriter = _capture_stats_loop(tmp_path, mock_event)
165-
assert stats_loop is not None
166-
stats_loop()
178+
mock_db = _run_stats_loop(tmp_path, mock_event)
167179

168-
MockDBWriter.return_value.refresh_windowed_stats.assert_called_once()
169-
MockDBWriter.return_value.close.assert_called_once()
180+
mock_db.refresh_windowed_stats.assert_called_once()
181+
mock_db.close.assert_called() # called by stats_loop (possibly also by main cleanup)
170182

171183

172184
def test_stats_loop_initial_refresh_error_is_swallowed(tmp_path):
@@ -175,10 +187,10 @@ def test_stats_loop_initial_refresh_error_is_swallowed(tmp_path):
175187
mock_event.is_set.return_value = False
176188
mock_event.wait.return_value = True
177189

178-
stats_loop, MockDBWriter = _capture_stats_loop(tmp_path, mock_event)
179-
assert stats_loop is not None
180-
MockDBWriter.return_value.refresh_windowed_stats.side_effect = Exception("stats error")
181-
stats_loop() # must not raise
190+
def configure(db):
191+
db.refresh_windowed_stats.side_effect = Exception("stats error")
192+
193+
_run_stats_loop(tmp_path, mock_event, configure_db=configure) # must not raise
182194

183195

184196
def test_stats_loop_calls_refresh_windowed_stats_in_timer_loop(tmp_path):
@@ -188,12 +200,10 @@ def test_stats_loop_calls_refresh_windowed_stats_in_timer_loop(tmp_path):
188200
# First timer wait → False (enter loop body), second → True (exit)
189201
mock_event.wait.side_effect = [False, True]
190202

191-
stats_loop, MockDBWriter = _capture_stats_loop(tmp_path, mock_event)
192-
assert stats_loop is not None
193-
stats_loop()
203+
mock_db = _run_stats_loop(tmp_path, mock_event)
194204

195205
# startup call + 1 timer-loop call = 2 total
196-
assert MockDBWriter.return_value.refresh_windowed_stats.call_count == 2
206+
assert mock_db.refresh_windowed_stats.call_count == 2
197207

198208

199209
def test_stats_loop_creates_dbwriter_with_config_user_id(tmp_path):

0 commit comments

Comments
 (0)