|
| 1 | +"""Unit tests for the `clean_document` management command.""" |
| 2 | + |
| 3 | +from unittest import mock |
| 4 | +from uuid import uuid4 |
| 5 | + |
| 6 | +from django.core.management import CommandError, call_command |
| 7 | + |
| 8 | +import pytest |
| 9 | +from botocore.exceptions import ClientError |
| 10 | + |
| 11 | +from core import factories, models |
| 12 | +from core.choices import LinkReachChoices, LinkRoleChoices |
| 13 | + |
| 14 | + |
| 15 | +@pytest.mark.django_db |
| 16 | +def test_clean_document_with_descendants(settings): |
| 17 | + """The command should reset the root (keeping title) and delete descendants.""" |
| 18 | + settings.DEBUG = True |
| 19 | + |
| 20 | + # Create a root document with subdocuments |
| 21 | + root = factories.DocumentFactory( |
| 22 | + title="Root", |
| 23 | + link_reach=LinkReachChoices.PUBLIC, |
| 24 | + link_role=LinkRoleChoices.EDITOR, |
| 25 | + ) |
| 26 | + child = factories.DocumentFactory( |
| 27 | + parent=root, |
| 28 | + title="Child", |
| 29 | + link_reach=LinkReachChoices.AUTHENTICATED, |
| 30 | + link_role=LinkRoleChoices.EDITOR, |
| 31 | + ) |
| 32 | + grandchild = factories.DocumentFactory( |
| 33 | + parent=child, |
| 34 | + title="Grandchild", |
| 35 | + ) |
| 36 | + |
| 37 | + # Create accesses and invitations |
| 38 | + factories.UserDocumentAccessFactory(document=root) |
| 39 | + factories.UserDocumentAccessFactory(document=child) |
| 40 | + factories.InvitationFactory(document=root) |
| 41 | + factories.InvitationFactory(document=child) |
| 42 | + |
| 43 | + with mock.patch( |
| 44 | + "core.management.commands.clean_document.default_storage" |
| 45 | + ) as mock_storage: |
| 46 | + call_command("clean_document", str(root.id), "--force") |
| 47 | + |
| 48 | + # Root document should still exist with title kept and other fields reset |
| 49 | + root.refresh_from_db() |
| 50 | + assert root.title == "Root" |
| 51 | + assert root.excerpt is None |
| 52 | + assert root.link_reach == LinkReachChoices.RESTRICTED |
| 53 | + assert root.link_role == LinkRoleChoices.READER |
| 54 | + assert root.attachments == [] |
| 55 | + |
| 56 | + # Accesses and invitations on root should be deleted |
| 57 | + assert not models.DocumentAccess.objects.filter(document=root).exists() |
| 58 | + assert not models.Invitation.objects.filter(document=root).exists() |
| 59 | + |
| 60 | + # Descendants should be deleted entirely |
| 61 | + assert not models.Document.objects.filter(id__in=[child.id, grandchild.id]).exists() |
| 62 | + |
| 63 | + # Root should have no descendants |
| 64 | + root.refresh_from_db() |
| 65 | + assert root.get_descendants().count() == 0 |
| 66 | + |
| 67 | + # S3 delete should have been called for document files + attachments |
| 68 | + delete_calls = mock_storage.connection.meta.client.delete_object.call_args_list |
| 69 | + assert len(delete_calls) == 3 |
| 70 | + |
| 71 | + |
| 72 | +@pytest.mark.django_db |
| 73 | +def test_clean_document_invalid_uuid(settings): |
| 74 | + """The command should raise an error for a non-existent document.""" |
| 75 | + settings.DEBUG = True |
| 76 | + |
| 77 | + fake_id = str(uuid4()) |
| 78 | + with pytest.raises(CommandError, match=f"Document {fake_id} does not exist."): |
| 79 | + call_command("clean_document", fake_id, "--force") |
| 80 | + |
| 81 | + |
| 82 | +@pytest.mark.django_db |
| 83 | +def test_clean_document_no_force_in_production(settings): |
| 84 | + """The command should require --force when DEBUG is False.""" |
| 85 | + settings.DEBUG = False |
| 86 | + |
| 87 | + doc = factories.DocumentFactory() |
| 88 | + with pytest.raises(CommandError, match="not meant to be used in production"): |
| 89 | + call_command("clean_document", str(doc.id)) |
| 90 | + |
| 91 | + |
| 92 | +@pytest.mark.django_db |
| 93 | +def test_clean_document_single_document(settings): |
| 94 | + """The command should work on a single document without children.""" |
| 95 | + settings.DEBUG = True |
| 96 | + |
| 97 | + doc = factories.DocumentFactory( |
| 98 | + title="Single", |
| 99 | + link_reach=LinkReachChoices.PUBLIC, |
| 100 | + link_role=LinkRoleChoices.EDITOR, |
| 101 | + ) |
| 102 | + access = factories.UserDocumentAccessFactory(document=doc) |
| 103 | + invitation = factories.InvitationFactory(document=doc) |
| 104 | + |
| 105 | + with mock.patch( |
| 106 | + "core.management.commands.clean_document.default_storage" |
| 107 | + ) as mock_storage: |
| 108 | + call_command("clean_document", str(doc.id), "--force") |
| 109 | + |
| 110 | + assert not models.DocumentAccess.objects.filter(id=access.id).exists() |
| 111 | + assert not models.Invitation.objects.filter(id=invitation.id).exists() |
| 112 | + |
| 113 | + doc.refresh_from_db() |
| 114 | + assert doc.title == "Single" |
| 115 | + assert doc.excerpt is None |
| 116 | + assert doc.link_reach == LinkReachChoices.RESTRICTED |
| 117 | + assert doc.link_role == LinkRoleChoices.READER |
| 118 | + assert doc.attachments == [] |
| 119 | + |
| 120 | + mock_storage.connection.meta.client.delete_object.assert_called_once() |
| 121 | + |
| 122 | + |
| 123 | +@pytest.mark.django_db |
| 124 | +def test_clean_document_with_title_option(settings): |
| 125 | + """The --title option should update the document title.""" |
| 126 | + settings.DEBUG = True |
| 127 | + |
| 128 | + doc = factories.DocumentFactory( |
| 129 | + title="Old Title", |
| 130 | + link_reach=LinkReachChoices.PUBLIC, |
| 131 | + link_role=LinkRoleChoices.EDITOR, |
| 132 | + ) |
| 133 | + |
| 134 | + with mock.patch("core.management.commands.clean_document.default_storage"): |
| 135 | + call_command("clean_document", str(doc.id), "--force", "--title", "New Title") |
| 136 | + |
| 137 | + doc.refresh_from_db() |
| 138 | + assert doc.title == "New Title" |
| 139 | + assert doc.excerpt is None |
| 140 | + assert doc.link_reach == LinkReachChoices.RESTRICTED |
| 141 | + assert doc.link_role == LinkRoleChoices.READER |
| 142 | + assert doc.attachments == [] |
| 143 | + |
| 144 | + |
| 145 | +@pytest.mark.django_db |
| 146 | +def test_clean_document_deletes_attachments_from_s3(settings): |
| 147 | + """The command should delete attachment files from S3.""" |
| 148 | + settings.DEBUG = True |
| 149 | + |
| 150 | + root = factories.DocumentFactory( |
| 151 | + attachments=["root-id/attachments/file1.png", "root-id/attachments/file2.pdf"], |
| 152 | + ) |
| 153 | + child = factories.DocumentFactory( |
| 154 | + parent=root, |
| 155 | + attachments=["child-id/attachments/file3.png"], |
| 156 | + ) |
| 157 | + |
| 158 | + with mock.patch( |
| 159 | + "core.management.commands.clean_document.default_storage" |
| 160 | + ) as mock_storage: |
| 161 | + call_command("clean_document", str(root.id), "--force") |
| 162 | + |
| 163 | + delete_calls = mock_storage.connection.meta.client.delete_object.call_args_list |
| 164 | + deleted_keys = [call.kwargs["Key"] for call in delete_calls] |
| 165 | + |
| 166 | + # Document files (root + child) |
| 167 | + assert root.file_key in deleted_keys |
| 168 | + assert child.file_key in deleted_keys |
| 169 | + |
| 170 | + # Attachment files |
| 171 | + assert "root-id/attachments/file1.png" in deleted_keys |
| 172 | + assert "root-id/attachments/file2.pdf" in deleted_keys |
| 173 | + assert "child-id/attachments/file3.png" in deleted_keys |
| 174 | + |
| 175 | + assert len(delete_calls) == 5 |
| 176 | + |
| 177 | + |
| 178 | +@pytest.mark.django_db |
| 179 | +def test_clean_document_s3_errors_do_not_stop_command(settings): |
| 180 | + """S3 deletion errors should be logged but not stop the command.""" |
| 181 | + settings.DEBUG = True |
| 182 | + |
| 183 | + doc = factories.DocumentFactory( |
| 184 | + attachments=["doc-id/attachments/file1.png"], |
| 185 | + ) |
| 186 | + |
| 187 | + with mock.patch( |
| 188 | + "core.management.commands.clean_document.default_storage" |
| 189 | + ) as mock_storage: |
| 190 | + mock_storage.connection.meta.client.delete_object.side_effect = ClientError( |
| 191 | + {"Error": {"Code": "500", "Message": "Internal Error"}}, |
| 192 | + "DeleteObject", |
| 193 | + ) |
| 194 | + # Command should complete without raising |
| 195 | + call_command("clean_document", str(doc.id), "--force") |
0 commit comments