Skip to content

Commit 6e3b9bd

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

5 files changed

Lines changed: 149 additions & 9 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: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,19 @@
44
from __future__ import annotations
55

66
import os
7-
from datetime import datetime
87
import shutil
9-
from tempfile import mkdtemp
108
import zipfile
9+
from datetime import datetime
10+
from tempfile import mkdtemp
1111

1212
from django.conf import settings
1313
from django.contrib.auth import get_user_model
1414
from django.utils.text import slugify
1515
from opaque_keys.edx.locator import LibraryLocatorV2, log
16-
from path import Path
17-
1816
from openedx_content.api import create_zip_file as create_lib_zip_file
17+
from path import Path
1918

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

2221

2322
def create_library_v2_zip(library_key: LibraryLocatorV2, user) -> tuple:
@@ -44,7 +43,7 @@ def create_library_v2_zip(library_key: LibraryLocatorV2, user) -> tuple:
4443
return root_dir, file_path
4544

4645

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

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

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

0 commit comments

Comments
 (0)