@@ -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
158172def 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
172184def 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
184196def 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
199209def test_stats_loop_creates_dbwriter_with_config_user_id (tmp_path ):
0 commit comments