|
7 | 7 | import uuid |
8 | 8 | from unittest import mock |
9 | 9 |
|
| 10 | +from django.db import transaction |
10 | 11 | from django.test import TestCase |
11 | 12 | from user_tasks.models import UserTaskStatus |
12 | 13 |
|
|
18 | 19 | from opaque_keys.edx.locator import LibraryContainerLocator, LibraryLocatorV2, LibraryUsageLocatorV2 |
19 | 20 | from openedx_events.content_authoring.data import ( |
20 | 21 | ContentObjectChangedData, |
| 22 | + LibraryBlockData, |
21 | 23 | LibraryCollectionData, |
22 | 24 | LibraryContainerData, |
23 | 25 | ) |
24 | 26 | from openedx_events.content_authoring.signals import ( |
25 | 27 | CONTENT_OBJECT_ASSOCIATIONS_CHANGED, |
| 28 | + LIBRARY_BLOCK_CREATED, |
| 29 | + LIBRARY_BLOCK_DELETED, |
| 30 | + LIBRARY_BLOCK_UPDATED, |
26 | 31 | LIBRARY_COLLECTION_CREATED, |
27 | 32 | LIBRARY_COLLECTION_DELETED, |
28 | 33 | LIBRARY_COLLECTION_UPDATED, |
| 34 | + LIBRARY_CONTAINER_CREATED, |
| 35 | + LIBRARY_CONTAINER_DELETED, |
29 | 36 | LIBRARY_CONTAINER_UPDATED, |
30 | 37 | ) |
| 38 | +from openedx_content.models_api import Component, Container |
31 | 39 | from openedx_authz.api.users import get_user_role_assignments_in_scope |
32 | 40 | from openedx_content import api as content_api |
33 | 41 | from openedx_content import models_api as content_models |
@@ -660,6 +668,69 @@ def test_delete_library_container(self) -> None: |
660 | 668 | }, |
661 | 669 | ) |
662 | 670 |
|
| 671 | + def test_delete_container_when_container_does_not_exist(self) -> None: |
| 672 | + """ |
| 673 | + Test that delete_container raises Container.DoesNotExist and still sends |
| 674 | + LIBRARY_CONTAINER_DELETED (to clean up stale search-index entries) when |
| 675 | + the Container does not exist in the DB. |
| 676 | + """ |
| 677 | + container_key = LibraryContainerLocator.from_string(self.unit1["id"]) |
| 678 | + |
| 679 | + event_receiver = mock.Mock() |
| 680 | + LIBRARY_CONTAINER_DELETED.connect(event_receiver) |
| 681 | + self.addCleanup(LIBRARY_CONTAINER_DELETED.disconnect, event_receiver) |
| 682 | + |
| 683 | + with mock.patch( |
| 684 | + "openedx.core.djangoapps.content_libraries.api.containers.get_container_from_key", |
| 685 | + side_effect=Container.DoesNotExist, |
| 686 | + ), mock.patch("openedx_content.api.soft_delete_draft") as mock_soft_delete: |
| 687 | + with self.assertRaises(Container.DoesNotExist): |
| 688 | + api.delete_container(container_key) |
| 689 | + mock_soft_delete.assert_not_called() |
| 690 | + |
| 691 | + assert event_receiver.call_count == 1 |
| 692 | + self.assertDictContainsEntries( |
| 693 | + event_receiver.call_args_list[0].kwargs, |
| 694 | + { |
| 695 | + "signal": LIBRARY_CONTAINER_DELETED, |
| 696 | + "library_container": LibraryContainerData( |
| 697 | + container_key=container_key, |
| 698 | + ), |
| 699 | + }, |
| 700 | + ) |
| 701 | + |
| 702 | + def test_delete_library_block_when_component_does_not_exist(self) -> None: |
| 703 | + """ |
| 704 | + Test that delete_library_block raises Component.DoesNotExist and still sends |
| 705 | + LIBRARY_BLOCK_DELETED (to clean up stale search-index entries) when the |
| 706 | + Component does not exist in the DB. |
| 707 | + """ |
| 708 | + usage_key = LibraryUsageLocatorV2.from_string(self.lib1_problem_block["id"]) |
| 709 | + |
| 710 | + event_receiver = mock.Mock() |
| 711 | + LIBRARY_BLOCK_DELETED.connect(event_receiver) |
| 712 | + self.addCleanup(LIBRARY_BLOCK_DELETED.disconnect, event_receiver) |
| 713 | + |
| 714 | + with mock.patch( |
| 715 | + "openedx.core.djangoapps.content_libraries.api.blocks.get_component_from_usage_key", |
| 716 | + side_effect=Component.DoesNotExist, |
| 717 | + ), mock.patch("openedx_content.api.soft_delete_draft") as mock_soft_delete: |
| 718 | + with self.assertRaises(Component.DoesNotExist): |
| 719 | + api.delete_library_block(usage_key) |
| 720 | + mock_soft_delete.assert_not_called() |
| 721 | + |
| 722 | + assert event_receiver.call_count == 1 |
| 723 | + self.assertDictContainsEntries( |
| 724 | + event_receiver.call_args_list[0].kwargs, |
| 725 | + { |
| 726 | + "signal": LIBRARY_BLOCK_DELETED, |
| 727 | + "library_block": LibraryBlockData( |
| 728 | + library_key=self.lib1.library_key, |
| 729 | + usage_key=usage_key, |
| 730 | + ), |
| 731 | + }, |
| 732 | + ) |
| 733 | + |
663 | 734 | def test_restore_library_block(self) -> None: |
664 | 735 | api.update_library_collection_items( |
665 | 736 | self.lib1.library_key, |
@@ -1391,6 +1462,101 @@ def test_copy_and_paste_container_another_library(self) -> None: |
1391 | 1462 | # This is the same unit, so it should not be duplicated |
1392 | 1463 | assert units_subsection1[0].container_key == units_subsection2[0].container_key |
1393 | 1464 |
|
| 1465 | + def test_set_library_block_olx_no_signal_on_rollback(self) -> None: |
| 1466 | + """ |
| 1467 | + LIBRARY_BLOCK_UPDATED is NOT emitted when set_library_block_olx is called |
| 1468 | + within a transaction that is later rolled back. |
| 1469 | + """ |
| 1470 | + event_receiver = mock.Mock() |
| 1471 | + LIBRARY_BLOCK_UPDATED.connect(event_receiver) |
| 1472 | + self.addCleanup(LIBRARY_BLOCK_UPDATED.disconnect, event_receiver) |
| 1473 | + |
| 1474 | + try: |
| 1475 | + with transaction.atomic(): |
| 1476 | + api.set_library_block_olx( |
| 1477 | + self.problem_block_usage_key, |
| 1478 | + "<problem>Updated inside rolled-back transaction</problem>", |
| 1479 | + ) |
| 1480 | + raise RuntimeError("Force rollback") |
| 1481 | + except RuntimeError: |
| 1482 | + pass |
| 1483 | + |
| 1484 | + assert event_receiver.call_count == 0 |
| 1485 | + |
| 1486 | + def test_set_library_block_olx_signal_emitted_on_success(self) -> None: |
| 1487 | + """ |
| 1488 | + LIBRARY_BLOCK_UPDATED IS emitted when set_library_block_olx completes |
| 1489 | + successfully. |
| 1490 | + """ |
| 1491 | + event_receiver = mock.Mock() |
| 1492 | + LIBRARY_BLOCK_UPDATED.connect(event_receiver) |
| 1493 | + self.addCleanup(LIBRARY_BLOCK_UPDATED.disconnect, event_receiver) |
| 1494 | + |
| 1495 | + api.set_library_block_olx( |
| 1496 | + self.problem_block_usage_key, |
| 1497 | + "<problem>Updated successfully</problem>", |
| 1498 | + ) |
| 1499 | + |
| 1500 | + assert event_receiver.call_count == 1 |
| 1501 | + self.assertDictContainsEntries( |
| 1502 | + event_receiver.call_args_list[0].kwargs, |
| 1503 | + { |
| 1504 | + "signal": LIBRARY_BLOCK_UPDATED, |
| 1505 | + "library_block": LibraryBlockData( |
| 1506 | + library_key=self.lib1.library_key, |
| 1507 | + usage_key=self.problem_block_usage_key, |
| 1508 | + ), |
| 1509 | + }, |
| 1510 | + ) |
| 1511 | + |
| 1512 | + def test_import_container_no_signals_on_failure(self) -> None: |
| 1513 | + """ |
| 1514 | + When import_staged_content_from_user_clipboard fails mid-way, none of |
| 1515 | + LIBRARY_CONTAINER_CREATED, LIBRARY_BLOCK_CREATED, or LIBRARY_BLOCK_UPDATED |
| 1516 | + are emitted, so the search index is not polluted with orphan entries. |
| 1517 | + """ |
| 1518 | + api.copy_container(self.unit1.container_key, self.user.id) |
| 1519 | + |
| 1520 | + event_receiver = mock.Mock() |
| 1521 | + for signal in [LIBRARY_CONTAINER_CREATED, LIBRARY_BLOCK_CREATED, LIBRARY_BLOCK_UPDATED]: |
| 1522 | + signal.connect(event_receiver) |
| 1523 | + self.addCleanup(signal.disconnect, event_receiver) |
| 1524 | + |
| 1525 | + # Simulate a failure at the last step of the import (after the container |
| 1526 | + # and its child components have been created in the DB). |
| 1527 | + with mock.patch( |
| 1528 | + "openedx.core.djangoapps.content_libraries.api.blocks.update_container_children", |
| 1529 | + side_effect=RuntimeError("Simulated failure"), |
| 1530 | + ), self.assertRaises(RuntimeError): |
| 1531 | + api.import_staged_content_from_user_clipboard(self.lib1.library_key, self.user) |
| 1532 | + |
| 1533 | + assert event_receiver.call_count == 0 |
| 1534 | + |
| 1535 | + def test_import_container_signals_emitted_on_success(self) -> None: |
| 1536 | + """ |
| 1537 | + When import_staged_content_from_user_clipboard succeeds, LIBRARY_CONTAINER_CREATED |
| 1538 | + is emitted for the new container. |
| 1539 | + """ |
| 1540 | + api.copy_container(self.unit1.container_key, self.user.id) |
| 1541 | + |
| 1542 | + container_created_receiver = mock.Mock() |
| 1543 | + LIBRARY_CONTAINER_CREATED.connect(container_created_receiver) |
| 1544 | + self.addCleanup(LIBRARY_CONTAINER_CREATED.disconnect, container_created_receiver) |
| 1545 | + |
| 1546 | + new_container = api.import_staged_content_from_user_clipboard(self.lib1.library_key, self.user) |
| 1547 | + |
| 1548 | + assert container_created_receiver.call_count == 1 |
| 1549 | + assert hasattr(new_container, "container_key") |
| 1550 | + self.assertDictContainsEntries( |
| 1551 | + container_created_receiver.call_args_list[0].kwargs, |
| 1552 | + { |
| 1553 | + "signal": LIBRARY_CONTAINER_CREATED, |
| 1554 | + "library_container": LibraryContainerData( |
| 1555 | + container_key=new_container.container_key, # type: ignore[union-attr] |
| 1556 | + ), |
| 1557 | + }, |
| 1558 | + ) |
| 1559 | + |
1394 | 1560 |
|
1395 | 1561 | class ContentLibraryExportTest(ContentLibrariesRestApiTest): |
1396 | 1562 | """ |
|
0 commit comments