Skip to content

Commit a7f35b6

Browse files
test: content library export tests added
1 parent 1cf9ae2 commit a7f35b6

5 files changed

Lines changed: 147 additions & 5 deletions

File tree

cms/djangoapps/contentstore/git_export_utils.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from django.utils.translation import gettext_lazy as _
1616
from opaque_keys.edx.locator import LibraryLocator, LibraryLocatorV2
1717

18-
from openedx.core.djangoapps.content_libraries.api import export_library_v2_to_dir
18+
from openedx.core.djangoapps.content_libraries.api import extract_library_v2_zip_to_dir
1919
from xmodule.contentstore.django import contentstore
2020
from xmodule.modulestore.django import modulestore
2121
from xmodule.modulestore.xml_exporter import export_course_to_xml, export_library_to_xml
@@ -88,7 +88,7 @@ def export_to_git(content_key, repo, user='', rdir=None):
8888
is_library_v2 = isinstance(content_key, LibraryLocatorV2)
8989
if is_library_v2:
9090
# V2 libraries use backup API with zip extraction
91-
content_export_func = export_library_v2_to_dir
91+
content_export_func = extract_library_v2_zip_to_dir
9292
elif isinstance(content_key, LibraryLocator):
9393
content_export_func = export_library_to_xml
9494
else:

cms/djangoapps/contentstore/management/commands/tests/test_git_export.py

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,14 @@
99
import subprocess
1010
import unittest
1111
from io import StringIO
12+
from unittest.mock import patch
1213
from uuid import uuid4
1314

1415
from django.conf import settings
1516
from django.core.management import call_command
1617
from django.core.management.base import CommandError
1718
from django.test.utils import override_settings
18-
from opaque_keys.edx.locator import CourseLocator
19+
from opaque_keys.edx.locator import CourseLocator, LibraryLocator, LibraryLocatorV2
1920

2021
import cms.djangoapps.contentstore.git_export_utils as git_export_utils
2122
from cms.djangoapps.contentstore.git_export_utils import GitExportError
@@ -34,6 +35,9 @@ class TestGitExport(CourseTestCase):
3435
Excercise the git_export django management command with various inputs.
3536
"""
3637

38+
LIBRARY_V2_KEY = LibraryLocatorV2(org='TestOrg', slug='test-lib')
39+
LIBRARY_V1_KEY = LibraryLocator(org='TestOrg', library='test-lib')
40+
3741
def setUp(self):
3842
"""
3943
Create/reinitialize bare repo and folders needed
@@ -182,3 +186,52 @@ def test_no_change(self):
182186
with self.assertRaisesRegex(GitExportError, str(GitExportError.CANNOT_COMMIT)):
183187
git_export_utils.export_to_git(
184188
self.course.id, f'file://{self.bare_repo_dir}')
189+
190+
@patch('cms.djangoapps.contentstore.git_export_utils.cmd_log', return_value=b'main')
191+
@patch('cms.djangoapps.contentstore.git_export_utils.extract_library_v2_zip_to_dir')
192+
def test_library_v2_export_selects_correct_function(self, mock_extract, mock_cmd_log):
193+
"""
194+
When ``export_to_git`` is given a LibraryLocatorV2 key it must call
195+
``extract_library_v2_zip_to_dir`` and must not call the v1 XML export
196+
functions (``export_library_to_xml`` or ``export_course_to_xml``).
197+
cmd_log is mocked so no real git subprocess or repo state is needed.
198+
"""
199+
mock_extract.return_value = None
200+
repo_url = f'file://{self.bare_repo_dir}'
201+
202+
with patch('cms.djangoapps.contentstore.git_export_utils.export_course_to_xml') as mock_course_xml, \
203+
patch('cms.djangoapps.contentstore.git_export_utils.export_library_to_xml') as mock_lib_xml:
204+
git_export_utils.export_to_git(self.LIBRARY_V2_KEY, repo_url, self.user.username)
205+
206+
assert mock_extract.call_args[0][0] == self.LIBRARY_V2_KEY
207+
mock_course_xml.assert_not_called()
208+
mock_lib_xml.assert_not_called()
209+
210+
@patch('cms.djangoapps.contentstore.git_export_utils.cmd_log', return_value=b'main')
211+
@patch('cms.djangoapps.contentstore.git_export_utils.export_library_to_xml')
212+
def test_library_v1_export_selects_correct_function(self, mock_lib_xml, mock_cmd_log):
213+
"""
214+
When ``export_to_git`` is given a LibraryLocator (v1) key it must call
215+
``export_library_to_xml`` and must not call ``extract_library_v2_zip_to_dir``.
216+
cmd_log is mocked so no real git subprocess or repo state is needed.
217+
"""
218+
mock_lib_xml.return_value = None
219+
repo_url = f'file://{self.bare_repo_dir}'
220+
221+
with patch('cms.djangoapps.contentstore.git_export_utils.extract_library_v2_zip_to_dir') as mock_v2, \
222+
patch('cms.djangoapps.contentstore.git_export_utils.export_course_to_xml') as mock_course_xml:
223+
git_export_utils.export_to_git(self.LIBRARY_V1_KEY, repo_url, self.user.username)
224+
225+
assert mock_lib_xml.called
226+
mock_v2.assert_not_called()
227+
mock_course_xml.assert_not_called()
228+
229+
@patch('cms.djangoapps.contentstore.git_export_utils.extract_library_v2_zip_to_dir',
230+
side_effect=OSError('disk full'))
231+
def test_library_v2_export_failure_raises_xml_export_fail(self, mock_extract):
232+
"""
233+
If ``extract_library_v2_zip_to_dir`` raises, ``export_to_git`` should
234+
wrap it in ``GitExportError.XML_EXPORT_FAIL``.
235+
"""
236+
with self.assertRaisesRegex(GitExportError, str(GitExportError.XML_EXPORT_FAIL)):
237+
git_export_utils.export_to_git(self.LIBRARY_V2_KEY, f'file://{self.bare_repo_dir}')

openedx/core/djangoapps/content_libraries/api/backup.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
from openedx_content.api import create_zip_file as create_lib_zip_file
1919

20-
__all__ = ["create_library_v2_zip", "export_library_v2_to_dir"]
20+
__all__ = ["create_library_v2_zip", "extract_library_v2_zip_to_dir"]
2121

2222

2323
def create_library_v2_zip(library_key: LibraryLocatorV2, user) -> tuple:
@@ -44,7 +44,7 @@ def create_library_v2_zip(library_key: LibraryLocatorV2, user) -> tuple:
4444
return root_dir, file_path
4545

4646

47-
def export_library_v2_to_dir(library_key, root_dir, library_dir, user=None):
47+
def extract_library_v2_zip_to_dir(library_key, root_dir, library_dir, user=None):
4848
"""
4949
Export a v2 library to a directory by creating a zip backup and extracting it.
5050

openedx/core/djangoapps/content_libraries/api/tests/__init__.py

Whitespace-only changes.
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
"""
2+
Tests for content library backup (zip export) utilities.
3+
"""
4+
from __future__ import annotations
5+
6+
import shutil
7+
import tempfile
8+
import zipfile
9+
from unittest.mock import MagicMock, patch
10+
11+
import pytest
12+
from django.test import TestCase
13+
from opaque_keys.edx.locator import LibraryLocatorV2
14+
from path import Path
15+
16+
from openedx.core.djangoapps.content_libraries.api.backup import extract_library_v2_zip_to_dir
17+
18+
19+
LIBRARY_KEY = LibraryLocatorV2(org='TestOrg', slug='test-lib')
20+
21+
22+
class TestExtractLibraryV2ZipToDir(TestCase):
23+
"""
24+
Tests for ``extract_library_v2_zip_to_dir``.
25+
"""
26+
27+
def _make_zip_in_temp_dir(self, contents=None):
28+
"""
29+
Helper: create a real temp dir + zip file and return (temp_dir_path, zip_path).
30+
``contents`` is a dict of {filename: bytes} to write into the zip.
31+
"""
32+
temp_dir = Path(tempfile.mkdtemp())
33+
zip_path = str(temp_dir / 'library.zip')
34+
with zipfile.ZipFile(zip_path, 'w') as zf:
35+
for name, data in (contents or {'data.xml': b'<library/>'}).items():
36+
zf.writestr(name, data)
37+
return temp_dir, zip_path
38+
39+
@patch('openedx.core.djangoapps.content_libraries.api.backup.get_user_model')
40+
@patch('openedx.core.djangoapps.content_libraries.api.backup.create_library_v2_zip')
41+
def test_successful_extraction(self, mock_create_zip, mock_get_user_model):
42+
"""
43+
On a successful call the function should:
44+
- resolve the username to a user object via the user model,
45+
- pass that user object to ``create_library_v2_zip``,
46+
- create the target directory if it does not already exist,
47+
- extract the zip contents into <root_dir>/<library_dir>,
48+
- clean up the temporary zip directory.
49+
"""
50+
root_dir = Path(tempfile.mkdtemp())
51+
temp_zip_dir, zip_path = self._make_zip_in_temp_dir({'content.xml': b'<lib/>'})
52+
mock_create_zip.return_value = (temp_zip_dir, zip_path)
53+
mock_user = MagicMock()
54+
mock_get_user_model.return_value.objects.filter.return_value.first.return_value = mock_user
55+
56+
try:
57+
target = root_dir / 'my-library'
58+
assert not target.exists(), "Target dir should not exist before the call"
59+
60+
extract_library_v2_zip_to_dir(LIBRARY_KEY, str(root_dir), 'my-library', user='testuser')
61+
62+
mock_get_user_model.return_value.objects.filter.assert_called_once_with(username='testuser')
63+
mock_create_zip.assert_called_once_with(LIBRARY_KEY, mock_user)
64+
assert target.isdir(), "Target dir should have been created"
65+
assert (target / 'content.xml').exists(), "Zip content should be extracted"
66+
assert not temp_zip_dir.exists(), "Temp zip dir should have been removed"
67+
finally:
68+
shutil.rmtree(root_dir, ignore_errors=True)
69+
shutil.rmtree(temp_zip_dir, ignore_errors=True)
70+
71+
@patch('openedx.core.djangoapps.content_libraries.api.backup.get_user_model')
72+
@patch('openedx.core.djangoapps.content_libraries.api.backup.create_library_v2_zip')
73+
def test_temp_dir_cleaned_up_even_on_extraction_error(self, mock_create_zip, mock_get_user_model):
74+
"""
75+
The temporary directory must be cleaned up even when extraction raises.
76+
"""
77+
root_dir = Path(tempfile.mkdtemp())
78+
temp_zip_dir, zip_path = self._make_zip_in_temp_dir()
79+
mock_create_zip.return_value = (temp_zip_dir, zip_path)
80+
mock_get_user_model.return_value.objects.filter.return_value.first.return_value = None
81+
82+
try:
83+
with patch('zipfile.ZipFile.extractall', side_effect=OSError('disk full')):
84+
with pytest.raises(OSError):
85+
extract_library_v2_zip_to_dir(LIBRARY_KEY, str(root_dir), 'my-library', user=None)
86+
assert not temp_zip_dir.exists(), "Temp dir should be cleaned up on error"
87+
finally:
88+
shutil.rmtree(root_dir, ignore_errors=True)
89+
shutil.rmtree(temp_zip_dir, ignore_errors=True)

0 commit comments

Comments
 (0)