11from django .test import TestCase , override_settings
22from unittest .mock import patch , Mock , mock_open , call
3+ import fcntl
34import os
45import tempfile
56import shutil
@@ -244,24 +245,11 @@ def setUp(self):
244245 original_content = f"Content { i } " ,
245246 )
246247
247- @patch ("core.management.commands.cleanup_entries.os.path.exists " )
248+ @patch ("core.management.commands.cleanup_entries.fcntl " )
248249 @patch ("core.management.commands.cleanup_entries.open" , new_callable = mock_open )
249250 @patch ("core.management.commands.cleanup_entries.cleanup_all_feeds" )
250- @patch ("core.management.commands.cleanup_entries.os.remove" )
251- def test_command_success (self , mock_remove , mock_cleanup , mock_file , mock_exists ):
251+ def test_command_success (self , mock_cleanup , mock_file , mock_fcntl ):
252252 """Test successful command execution."""
253- # Mock lock file doesn't exist initially, but exists after creation
254- mock_exists .side_effect = [False , True ]
255-
256- # Mock file operations
257- mock_file .return_value .__enter__ .return_value .write .return_value = None
258-
259- # Mock cleanup function
260- mock_cleanup .return_value = None
261-
262- # Mock os.remove
263- mock_remove .return_value = None
264-
265253 # Capture stdout
266254 from io import StringIO
267255
@@ -274,18 +262,18 @@ def test_command_success(self, mock_remove, mock_cleanup, mock_file, mock_exists
274262 # Verify cleanup was called
275263 mock_cleanup .assert_called_once ()
276264
277- # Verify lock file was created and removed
278- mock_file .assert_called_once ()
279- mock_remove .assert_called_once ()
265+ # Verify the flock was acquired
266+ mock_fcntl .flock .assert_called_once ()
280267
281268 # Verify success message
282269 self .assertIn ("Successfully cleaned up all feeds" , out .getvalue ())
283270
284- @patch ("core.management.commands.cleanup_entries.os.path.exists" )
285- def test_command_already_running (self , mock_exists ):
286- """Test command when cleanup is already running."""
287- # Mock lock file exists
288- mock_exists .return_value = True
271+ @patch ("core.management.commands.cleanup_entries.fcntl" )
272+ @patch ("core.management.commands.cleanup_entries.open" , new_callable = mock_open )
273+ @patch ("core.management.commands.cleanup_entries.cleanup_all_feeds" )
274+ def test_command_already_running (self , mock_cleanup , mock_file , mock_fcntl ):
275+ """Test command when cleanup is already running (flock held elsewhere)."""
276+ mock_fcntl .flock .side_effect = BlockingIOError ()
289277
290278 # Capture stdout
291279 from io import StringIO
@@ -303,30 +291,20 @@ def test_command_already_running(self, mock_exists):
303291 # Verify warning message
304292 self .assertIn ("Cleanup process is already running" , out .getvalue ())
305293
306- @patch ("core.management.commands.cleanup_entries.os.path.exists" )
294+ # Verify cleanup did not run
295+ mock_cleanup .assert_not_called ()
296+
297+ @patch ("core.management.commands.cleanup_entries.fcntl" )
307298 @patch ("core.management.commands.cleanup_entries.open" , new_callable = mock_open )
308299 @patch ("core.management.commands.cleanup_entries.cleanup_all_feeds" )
309- @patch ("core.management.commands.cleanup_entries.os.remove" )
310300 @patch ("core.management.commands.cleanup_entries.logger" )
311301 def test_command_exception_handling (
312- self , mock_logger , mock_remove , mock_cleanup , mock_file , mock_exists
302+ self , mock_logger , mock_cleanup , mock_file , mock_fcntl
313303 ):
314304 """Test command exception handling."""
315- # Mock lock file doesn't exist initially, but exists after creation
316- mock_exists .side_effect = [False , True ]
317-
318- # Mock file operations
319- mock_file .return_value .__enter__ .return_value .write .return_value = None
320-
321305 # Mock cleanup function to raise exception
322306 mock_cleanup .side_effect = Exception ("Test error" )
323307
324- # Mock os.remove
325- mock_remove .return_value = None
326-
327- # Mock logger to suppress output
328- mock_logger .exception .return_value = None
329-
330308 # Capture stderr
331309 from io import StringIO
332310
@@ -343,42 +321,54 @@ def test_command_exception_handling(
343321 # Verify error message
344322 self .assertIn ("Test error" , err .getvalue ())
345323
346- # Verify lock file was still removed
347- mock_remove .assert_called_once ()
348324
349- @patch ("core.management.commands.cleanup_entries.os.path.exists" )
350- @patch ("core.management.commands.cleanup_entries.open" , new_callable = mock_open )
351- @patch ("core.management.commands.cleanup_entries.cleanup_all_feeds" )
352- @patch ("core.management.commands.cleanup_entries.os.remove" )
353- @patch ("core.management.commands.cleanup_entries.logger" )
354- def test_command_lock_file_cleanup_on_exception (
355- self , mock_logger , mock_remove , mock_cleanup , mock_file , mock_exists
356- ):
357- """Test that lock file is cleaned up even when exception occurs."""
358- # Mock lock file doesn't exist initially, but exists after creation
359- mock_exists .side_effect = [False , True ]
325+ class CleanupEntriesLockRegressionTests (TestCase ):
326+ """Regression tests for issue #161 using a real lock file on disk."""
360327
361- # Mock file operations
362- mock_file .return_value .__enter__ .return_value .write .return_value = None
328+ lock_path = "/tmp/cleanup_entries.lock"
363329
364- # Mock cleanup function to raise exception
365- mock_cleanup .side_effect = Exception ("Test error" )
330+ def tearDown (self ):
331+ if os .path .exists (self .lock_path ):
332+ os .remove (self .lock_path )
366333
367- # Mock os.remove
368- mock_remove .return_value = None
334+ @patch ("core.management.commands.cleanup_entries.cleanup_all_feeds" )
335+ def test_stale_lock_file_does_not_block_cleanup (self , mock_cleanup ):
336+ """A leftover lock file from a dead process must not block the next run."""
337+ # Simulate the stale lock left behind by a killed process (issue #161):
338+ # the file exists and contains a dead PID, but nobody holds the flock.
339+ with open (self .lock_path , "w" ) as f :
340+ f .write ("999999" )
369341
370- # Mock logger to suppress output
371- mock_logger .exception .return_value = None
342+ command = Command ()
343+ with patch .object (command , "stdout" ), patch .object (command , "stderr" ):
344+ command .handle ()
372345
373- # Mock stderr to suppress error output
374- from io import StringIO
346+ mock_cleanup .assert_called_once ()
375347
376- mock_stderr = StringIO ()
377- self .command .stderr = mock_stderr
348+ @patch ("core.management.commands.cleanup_entries.cleanup_all_feeds" )
349+ def test_lock_held_by_live_process_blocks_cleanup (self , mock_cleanup ):
350+ """While a live process holds the flock, the command exits without cleaning."""
351+ with open (self .lock_path , "w" ) as holder :
352+ fcntl .flock (holder .fileno (), fcntl .LOCK_EX | fcntl .LOCK_NB )
378353
379- # Execute command
380- with self .assertRaises (SystemExit ):
381- self .command .handle ()
354+ command = Command ()
355+ with patch .object (command , "stdout" ), patch .object (command , "stderr" ):
356+ with self .assertRaises (SystemExit ) as ctx :
357+ command .handle ()
358+ self .assertEqual (ctx .exception .code , 0 )
359+
360+ mock_cleanup .assert_not_called ()
382361
383- # Verify lock file was removed in finally block
384- mock_remove .assert_called_once ()
362+ @patch ("core.management.commands.cleanup_entries.cleanup_all_feeds" )
363+ def test_lock_released_after_failed_run (self , mock_cleanup ):
364+ """A failed run releases the lock, so the next run still proceeds."""
365+ command = Command ()
366+ with patch .object (command , "stdout" ), patch .object (command , "stderr" ):
367+ mock_cleanup .side_effect = Exception ("boom" )
368+ with self .assertRaises (SystemExit ):
369+ command .handle ()
370+
371+ mock_cleanup .side_effect = None
372+ command .handle ()
373+
374+ self .assertEqual (mock_cleanup .call_count , 2 )
0 commit comments