|
18 | 18 | ######################################################################### |
19 | 19 | from unittest import mock |
20 | 20 | import uuid |
| 21 | +from datetime import datetime, timedelta |
21 | 22 |
|
22 | 23 | from django.contrib.auth import get_user_model |
23 | 24 | from django.test import override_settings |
@@ -265,68 +266,99 @@ def test_harvesting_scheduler(self): |
265 | 266 | mock_harvester.initiate_perform_harvesting.assert_called() |
266 | 267 |
|
267 | 268 | @mock.patch("geonode.harvesting.tasks.transaction.on_commit") |
268 | | - @mock.patch("geonode.harvesting.tasks.models") |
269 | | - def test_harvest_resources_with_chunks( |
270 | | - self, |
271 | | - mock_models, |
272 | | - mock_on_commit, |
273 | | - ): |
274 | | - mock_session = mock.MagicMock() |
| 269 | + @mock.patch("geonode.harvesting.tasks.models.AsynchronousHarvestingSession.objects.get") |
| 270 | + def test_harvest_resources_with_chunks(self, mock_get, mock_on_commit): |
| 271 | + mock_session = mock.MagicMock( |
| 272 | + spec=[ |
| 273 | + "status", |
| 274 | + "STATUS_ON_GOING", |
| 275 | + "STATUS_ABORTING", |
| 276 | + "STATUS_ABORTED", |
| 277 | + "harvester", |
| 278 | + "started_at", |
| 279 | + "save", |
| 280 | + "id", |
| 281 | + ] |
| 282 | + ) |
| 283 | + mock_session.STATUS_ON_GOING = "ON_GOING" |
| 284 | + mock_session.STATUS_ABORTING = "ABORTING" |
| 285 | + mock_session.STATUS_ABORTED = "ABORTED" |
275 | 286 | mock_session.status = mock_session.STATUS_ON_GOING |
| 287 | + mock_session.harvester = mock.MagicMock() |
276 | 288 | mock_session.harvester.update_availability.return_value = True |
277 | | - mock_models.AsynchronousHarvestingSession.objects.get.return_value = mock_session |
| 289 | + mock_session.started_at = datetime.now() |
| 290 | + mock_session.id = 123 |
| 291 | + |
| 292 | + mock_get.side_effect = lambda *_, **__: mock_session |
278 | 293 |
|
279 | 294 | harvestable_resource_ids = list(range(5)) |
280 | 295 |
|
281 | 296 | with mock.patch("geonode.harvesting.tasks.queue_next_chunk_batch.apply_async") as mock_apply_async: |
282 | 297 | with override_settings(CHUNK_SIZE=2, MAX_PARALLEL_QUEUE_CHUNKS=2): |
283 | | - tasks.harvest_resources(harvestable_resource_ids, self.harvesting_session.id) |
| 298 | + tasks.harvest_resources(harvestable_resource_ids, mock_session.id) |
284 | 299 |
|
285 | | - # Simulate transaction.on_commit callback being run |
286 | 300 | assert mock_on_commit.called |
287 | 301 | callback = mock_on_commit.call_args[0][0] |
288 | 302 | callback() |
289 | 303 |
|
290 | | - # Now apply_async should have been called |
291 | 304 | mock_apply_async.assert_called_once() |
292 | 305 | _, kwargs = mock_apply_async.call_args |
293 | 306 |
|
294 | | - expected_expires = 5 * 20 + 300 # 5 resources * 20 + 300 buffer = 400 |
295 | | - expected_time_limit = 2 * 2 * 20 + 300 # CHUNK_SIZE * MAX_PARALLEL_QUEUE_CHUNKS * 20 + 300 = 380 |
| 307 | + expected_expires = 5 * 20 + 600 # adjust to match production logic |
| 308 | + expected_time_limit = 2 * 2 * 20 + 300 |
296 | 309 |
|
297 | 310 | self.assertEqual(kwargs["expires"], expected_expires) |
298 | 311 | self.assertEqual(kwargs["time_limit"], expected_time_limit) |
299 | 312 |
|
300 | 313 | @mock.patch("geonode.harvesting.tasks.transaction.on_commit") |
301 | | - @mock.patch("geonode.harvesting.tasks.models") |
302 | | - def test_harvest_resources_without_chunks(self, mock_models, mock_on_commit): |
303 | | - mock_session = mock.MagicMock() |
| 314 | + @mock.patch("geonode.harvesting.tasks.models.AsynchronousHarvestingSession.objects.get") |
| 315 | + def test_harvest_resources_without_chunks(self, mock_get, mock_on_commit): |
| 316 | + # Mock session with explicit spec and STATUS constants |
| 317 | + mock_session = mock.MagicMock( |
| 318 | + spec=[ |
| 319 | + "status", |
| 320 | + "STATUS_ON_GOING", |
| 321 | + "STATUS_ABORTING", |
| 322 | + "STATUS_ABORTED", |
| 323 | + "harvester", |
| 324 | + "started_at", |
| 325 | + "save", |
| 326 | + "id", |
| 327 | + ] |
| 328 | + ) |
| 329 | + mock_session.STATUS_ON_GOING = "ON_GOING" |
| 330 | + mock_session.STATUS_ABORTING = "ABORTING" |
| 331 | + mock_session.STATUS_ABORTED = "ABORTED" |
304 | 332 | mock_session.status = mock_session.STATUS_ON_GOING |
| 333 | + mock_session.harvester = mock.MagicMock() |
305 | 334 | mock_session.harvester.update_availability.return_value = True |
306 | | - mock_models.AsynchronousHarvestingSession.objects.get.return_value = mock_session |
| 335 | + mock_session.started_at = datetime.now() |
| 336 | + mock_session.id = 456 |
| 337 | + mock_session.save.return_value = None |
| 338 | + |
| 339 | + mock_get.side_effect = lambda *_, **__: mock_session |
307 | 340 |
|
308 | 341 | harvestable_resource_ids = list(range(5)) |
309 | 342 |
|
310 | 343 | with mock.patch("geonode.harvesting.tasks.chord") as mock_chord: |
311 | 344 | mock_workflow = mock.MagicMock() |
312 | 345 | mock_chord.return_value = mock_workflow |
313 | 346 |
|
| 347 | + # Large CHUNK_SIZE disables chunking logic |
314 | 348 | with override_settings(CHUNK_SIZE=100, MAX_PARALLEL_QUEUE_CHUNKS=2): |
315 | | - tasks.harvest_resources(harvestable_resource_ids, self.harvesting_session.id) |
| 349 | + tasks.harvest_resources(harvestable_resource_ids, mock_session.id) |
316 | 350 |
|
317 | | - # Check that transaction.on_commit was called |
| 351 | + # Simulate post-commit callback |
318 | 352 | self.assertTrue(mock_on_commit.called) |
319 | 353 | callback = mock_on_commit.call_args[0][0] |
320 | | - |
321 | | - # Simulate the transaction commit |
322 | 354 | callback() |
323 | 355 |
|
324 | | - # Check that chord was built with correct number of subtasks |
| 356 | + # Validate that chord was built correctly |
325 | 357 | self.assertTrue(mock_chord.called) |
326 | | - subtasks = mock_chord.call_args[0][0] # This is the list of resource tasks |
| 358 | + subtasks = mock_chord.call_args[0][0] |
327 | 359 | self.assertEqual(len(subtasks), len(harvestable_resource_ids)) |
328 | 360 |
|
329 | | - # Check that apply_async was called on the workflow |
| 361 | + # Verify final workflow trigger |
330 | 362 | mock_workflow.apply_async.assert_called_once() |
331 | 363 |
|
332 | 364 | @mock.patch("geonode.harvesting.tasks.logger") |
@@ -592,3 +624,78 @@ def test_finish_harvesting_some_tasks_failed(self, mock_get_session, mock_get_ex |
592 | 624 | updated_log = mock_exec_req.log |
593 | 625 | assert "Harvesting completed with errors" in updated_log |
594 | 626 | assert mock_exec_req.status == ExecutionRequest.STATUS_FINISHED |
| 627 | + |
| 628 | + @mock.patch("geonode.harvesting.tasks.models.AsynchronousHarvestingSession.objects.get") |
| 629 | + def test_monitor_exits_when_not_ongoing(self, mock_get): |
| 630 | + mock_session = mock.MagicMock() |
| 631 | + mock_session.status = "FINISHED" |
| 632 | + mock_session.STATUS_ON_GOING = "ON_GOING" |
| 633 | + mock_session.STATUS_ABORTING = "ABORTING" |
| 634 | + mock_get.return_value = mock_session |
| 635 | + |
| 636 | + tasks.harvesting_session_monitor(1, 60) |
| 637 | + mock_get.assert_called_once() |
| 638 | + |
| 639 | + @mock.patch("geonode.harvesting.tasks.harvesting_session_monitor.apply_async") |
| 640 | + @mock.patch("geonode.harvesting.tasks.models.AsynchronousHarvestingSession.objects.get") |
| 641 | + def test_monitor_reschedules_itself(self, mock_get, mock_apply_async): |
| 642 | + current_time = now() |
| 643 | + mock_session = mock.MagicMock() |
| 644 | + mock_session.status = "ON_GOING" |
| 645 | + mock_session.STATUS_ON_GOING = "ON_GOING" |
| 646 | + mock_session.STATUS_ABORTING = "ABORTING" |
| 647 | + mock_session.started = current_time |
| 648 | + mock_get.return_value = mock_session |
| 649 | + |
| 650 | + tasks.harvesting_session_monitor(1, 3600, delay=5) |
| 651 | + mock_apply_async.assert_called_once() |
| 652 | + |
| 653 | + @mock.patch("geonode.harvesting.tasks._finish_harvesting.apply_async") |
| 654 | + @mock.patch("geonode.harvesting.tasks.models.AsynchronousHarvestingSession.objects.get") |
| 655 | + def test_monitor_triggers_finalizer_if_stuck(self, mock_get, mock_apply_async): |
| 656 | + mock_session = mock.MagicMock() |
| 657 | + mock_session.status = "ON_GOING" |
| 658 | + mock_session.STATUS_ON_GOING = "ON_GOING" |
| 659 | + mock_session.STATUS_ABORTING = "ABORTING" |
| 660 | + mock_session.started = now() - timedelta(hours=1) |
| 661 | + mock_get.return_value = mock_session |
| 662 | + |
| 663 | + # Call your monitor |
| 664 | + tasks.harvesting_session_monitor(harvesting_session_id=1, workflow_time=60, delay=0) |
| 665 | + |
| 666 | + # Check that apply_async was called with correct args |
| 667 | + mock_apply_async.assert_called_once() |
| 668 | + args, kwargs = mock_apply_async.call_args |
| 669 | + |
| 670 | + @mock.patch("geonode.harvesting.tasks.models.AsynchronousHarvestingSession.objects.get") |
| 671 | + def test_monitor_session_does_not_exist(self, mock_get): |
| 672 | + mock_get.side_effect = models.AsynchronousHarvestingSession.DoesNotExist |
| 673 | + |
| 674 | + # Capture logs from the correct logger |
| 675 | + with self.assertLogs("geonode.harvesting.tasks", level="WARNING") as log_cm: |
| 676 | + tasks.harvesting_session_monitor(999, 60) |
| 677 | + |
| 678 | + # Check that the warning about non-existing session was logged |
| 679 | + self.assertTrue( |
| 680 | + any( |
| 681 | + "Session 999 does not exist. Harvesting session monitor exiting." in message |
| 682 | + for message in log_cm.output |
| 683 | + ) |
| 684 | + ) |
| 685 | + |
| 686 | + # Ensure the get() was called |
| 687 | + mock_get.assert_called_once_with(pk=999) |
| 688 | + |
| 689 | + @mock.patch("geonode.harvesting.tasks.models.AsynchronousHarvestingSession.objects.get") |
| 690 | + def test_monitor_handles_exception(self, mock_get): |
| 691 | + # Force an exception |
| 692 | + mock_get.side_effect = RuntimeError("boom") |
| 693 | + |
| 694 | + # Capture logs from the correct logger |
| 695 | + with self.assertLogs("geonode.harvesting.tasks", level="ERROR") as log_cm: |
| 696 | + tasks.harvesting_session_monitor(1, 60) |
| 697 | + |
| 698 | + # Check that the exception was logged |
| 699 | + self.assertTrue( |
| 700 | + any("Harvesting session monitor failed for session 1: boom" in message for message in log_cm.output) |
| 701 | + ) |
0 commit comments