cmboulanger's picture
disaster-recovery deploy of pdf-tei-editor
6a49f21 verified
Raw
History Blame Contribute Delete
14 kB
"""
Unit tests for Local Sync Plugin.
@testCovers fastapi_app/plugins/local_sync/plugin.py
"""
import unittest
from unittest.mock import Mock, patch, MagicMock
from pathlib import Path
import tempfile
import shutil
class TestLocalSyncPlugin(unittest.TestCase):
def setUp(self):
"""Set up test fixtures."""
self.temp_dir = tempfile.mkdtemp()
def tearDown(self):
"""Clean up test fixtures."""
shutil.rmtree(self.temp_dir)
def test_scan_filesystem(self):
"""Test filesystem scanning for TEI files."""
# Create test files
test_path = Path(self.temp_dir)
(test_path / "doc1.tei.xml").write_text("<TEI>test1</TEI>")
(test_path / "subdir").mkdir()
(test_path / "subdir" / "doc2.tei.xml").write_text("<TEI>test2</TEI>")
(test_path / "ignore.xml").write_text("<TEI>ignore</TEI>")
from fastapi_app.plugins.local_sync.plugin import LocalSyncPlugin
plugin = LocalSyncPlugin()
docs = plugin._scan_filesystem(test_path)
self.assertEqual(len(docs), 2)
self.assertTrue(any("doc1.tei.xml" in str(p) for p in docs.keys()))
self.assertTrue(any("doc2.tei.xml" in str(p) for p in docs.keys()))
def test_scan_filesystem_with_include_filter(self):
"""Test filesystem scanning with include pattern."""
# Create test files
test_path = Path(self.temp_dir)
(test_path / "doc1.tei.xml").write_text("<TEI>test1</TEI>")
(test_path / "subdir").mkdir()
(test_path / "subdir" / "doc2.tei.xml").write_text("<TEI>test2</TEI>")
(test_path / "other.tei.xml").write_text("<TEI>other</TEI>")
from fastapi_app.plugins.local_sync.plugin import LocalSyncPlugin
plugin = LocalSyncPlugin()
# Only include files in subdir
docs = plugin._scan_filesystem(test_path, include_pattern=r"subdir")
self.assertEqual(len(docs), 1)
self.assertTrue(any("doc2.tei.xml" in str(p) for p in docs.keys()))
def test_scan_filesystem_with_exclude_filter(self):
"""Test filesystem scanning with exclude pattern."""
# Create test files
test_path = Path(self.temp_dir)
(test_path / "doc1.tei.xml").write_text("<TEI>test1</TEI>")
(test_path / "subdir").mkdir()
(test_path / "subdir" / "doc2.tei.xml").write_text("<TEI>test2</TEI>")
(test_path / "other.tei.xml").write_text("<TEI>other</TEI>")
from fastapi_app.plugins.local_sync.plugin import LocalSyncPlugin
plugin = LocalSyncPlugin()
# Exclude files in subdir
docs = plugin._scan_filesystem(test_path, exclude_pattern=r"subdir")
self.assertEqual(len(docs), 2)
self.assertTrue(any("doc1.tei.xml" in str(p) for p in docs.keys()))
self.assertTrue(any("other.tei.xml" in str(p) for p in docs.keys()))
def test_scan_filesystem_with_include_and_exclude_filters(self):
"""Test filesystem scanning with both include and exclude patterns."""
# Create test files
test_path = Path(self.temp_dir)
(test_path / "gold").mkdir()
(test_path / "gold" / "doc1.tei.xml").write_text("<TEI>test1</TEI>")
(test_path / "gold" / "doc2.tei.xml").write_text("<TEI>test2</TEI>")
(test_path / "gold" / "draft.tei.xml").write_text("<TEI>draft</TEI>")
(test_path / "draft").mkdir()
(test_path / "draft" / "doc3.tei.xml").write_text("<TEI>test3</TEI>")
from fastapi_app.plugins.local_sync.plugin import LocalSyncPlugin
plugin = LocalSyncPlugin()
# Include only files in gold, exclude files with "draft" in name
docs = plugin._scan_filesystem(test_path, include_pattern=r"gold", exclude_pattern=r"draft")
self.assertEqual(len(docs), 2)
self.assertTrue(any("doc1.tei.xml" in str(p) for p in docs.keys()))
self.assertTrue(any("doc2.tei.xml" in str(p) for p in docs.keys()))
self.assertFalse(any("draft.tei.xml" in str(p) for p in docs.keys()))
def test_scan_filesystem_with_filename_pattern(self):
"""Test filesystem scanning with filename-based pattern."""
# Create test files
test_path = Path(self.temp_dir)
(test_path / "article-001.tei.xml").write_text("<TEI>test1</TEI>")
(test_path / "article-002.tei.xml").write_text("<TEI>test2</TEI>")
(test_path / "review-001.tei.xml").write_text("<TEI>review</TEI>")
from fastapi_app.plugins.local_sync.plugin import LocalSyncPlugin
plugin = LocalSyncPlugin()
# Only include files starting with "article"
docs = plugin._scan_filesystem(test_path, include_pattern=r"article-\d+\.tei\.xml")
self.assertEqual(len(docs), 2)
self.assertTrue(any("article-001.tei.xml" in str(p) for p in docs.keys()))
self.assertTrue(any("article-002.tei.xml" in str(p) for p in docs.keys()))
self.assertFalse(any("review-001.tei.xml" in str(p) for p in docs.keys()))
def test_extract_fileref_from_xml_id(self):
"""Test fileref extraction from fileDesc/@xml:id (primary path)."""
tei_content = b"""<?xml version="1.0"?>
<TEI xmlns="http://www.tei-c.org/ns/1.0">
<teiHeader>
<fileDesc xml:id="_10.5771__2699-1284-2024-3-149">
<titleStmt><title>Test</title></titleStmt>
</fileDesc>
</teiHeader>
</TEI>"""
from fastapi_app.lib.utils.tei_utils import extract_fileref
fileref = extract_fileref(tei_content)
self.assertEqual(fileref, "10.5771__2699-1284-2024-3-149")
def test_extract_fileref_legacy_fallback(self):
"""Test fileref extraction from deprecated editionStmt/idno[@type='fileref'] fallback."""
tei_content = b"""<?xml version="1.0"?>
<TEI xmlns="http://www.tei-c.org/ns/1.0">
<teiHeader>
<fileDesc>
<editionStmt>
<edition>
<idno type="fileref">test-doc-123</idno>
</edition>
</editionStmt>
</fileDesc>
</teiHeader>
</TEI>"""
from fastapi_app.lib.utils.tei_utils import extract_fileref
fileref = extract_fileref(tei_content)
self.assertEqual(fileref, "test-doc-123")
def test_extract_fileref_no_match(self):
"""Test fileref extraction with missing element."""
tei_content = b"""<?xml version="1.0"?>
<TEI xmlns="http://www.tei-c.org/ns/1.0">
<teiHeader>
<fileDesc>
<titleStmt><title>Test</title></titleStmt>
</fileDesc>
</teiHeader>
</TEI>"""
from fastapi_app.lib.utils.tei_utils import extract_fileref
fileref = extract_fileref(tei_content)
self.assertIsNone(fileref)
def test_extract_timestamp(self):
"""Test timestamp extraction from last revision change using tei_utils."""
tei_content = b"""<?xml version="1.0"?>
<TEI xmlns="http://www.tei-c.org/ns/1.0">
<teiHeader>
<revisionDesc>
<change when="2025-01-01T10:00:00">First change</change>
<change when="2025-01-08T15:30:00">Latest change</change>
</revisionDesc>
</teiHeader>
</TEI>"""
from fastapi_app.lib.utils.tei_utils import extract_revision_timestamp
timestamp = extract_revision_timestamp(tei_content)
self.assertEqual(timestamp, "2025-01-08T15:30:00")
def test_extract_timestamp_no_changes(self):
"""Test timestamp extraction with no changes."""
tei_content = b"""<?xml version="1.0"?>
<TEI xmlns="http://www.tei-c.org/ns/1.0">
<teiHeader>
<fileDesc>
<titleStmt><title>Test</title></titleStmt>
</fileDesc>
</teiHeader>
</TEI>"""
from fastapi_app.lib.utils.tei_utils import extract_revision_timestamp
timestamp = extract_revision_timestamp(tei_content)
self.assertIsNone(timestamp)
@patch('fastapi_app.lib.plugins.plugin_tools.get_plugin_config')
def test_plugin_availability_disabled(self, mock_config):
"""Test plugin is not available when disabled."""
from fastapi_app.plugins.local_sync.plugin import LocalSyncPlugin
# Mock config to return disabled
def config_side_effect(key, env_var, default=None, value_type="string"):
if key == "plugin.local-sync.enabled":
return False
return None
mock_config.side_effect = config_side_effect
self.assertFalse(LocalSyncPlugin.is_available())
@patch('fastapi_app.lib.plugins.plugin_tools.get_plugin_config')
def test_plugin_availability_no_repo_path(self, mock_config):
"""Test plugin is not available when repo path not configured."""
from fastapi_app.plugins.local_sync.plugin import LocalSyncPlugin
# Mock config to return enabled but no repo path
def config_side_effect(key, env_var, default=None, value_type="string"):
if key == "plugin.local-sync.enabled":
return True
if key == "plugin.local-sync.repo.path":
return None
return None
mock_config.side_effect = config_side_effect
self.assertFalse(LocalSyncPlugin.is_available())
@patch('fastapi_app.lib.plugins.plugin_tools.get_plugin_config')
def test_plugin_availability_enabled(self, mock_config):
"""Test plugin is available when enabled and repo path configured."""
from fastapi_app.plugins.local_sync.plugin import LocalSyncPlugin
# Mock config to return enabled and repo path
def config_side_effect(key, env_var, default=None, value_type="string"):
if key == "plugin.local-sync.enabled":
return True
if key == "plugin.local-sync.repo.path":
return "/some/path"
return None
mock_config.side_effect = config_side_effect
self.assertTrue(LocalSyncPlugin.is_available())
def test_update_filesystem_with_backup(self):
"""Test filesystem update creates backup."""
from fastapi_app.plugins.local_sync.plugin import LocalSyncPlugin
plugin = LocalSyncPlugin()
test_path = Path(self.temp_dir)
test_file = test_path / "test.tei.xml"
test_file.write_text("old content")
plugin._update_filesystem(test_file, b"new content", backup_enabled=True)
# Check new content
self.assertEqual(test_file.read_text(), "new content")
# Check backup exists
backups = list(test_path.glob("test.*.backup"))
self.assertEqual(len(backups), 1)
self.assertEqual(backups[0].read_text(), "old content")
def test_update_filesystem_without_backup(self):
"""Test filesystem update without backup."""
from fastapi_app.plugins.local_sync.plugin import LocalSyncPlugin
plugin = LocalSyncPlugin()
test_path = Path(self.temp_dir)
test_file = test_path / "test.tei.xml"
test_file.write_text("old content")
plugin._update_filesystem(test_file, b"new content", backup_enabled=False)
# Check new content
self.assertEqual(test_file.read_text(), "new content")
# Check no backup exists
backups = list(test_path.glob("test.*.backup"))
self.assertEqual(len(backups), 0)
def test_create_new_version_preserves_processing_instructions(self):
"""Test that _create_new_version preserves processing instructions from filesystem."""
from fastapi_app.plugins.local_sync.plugin import LocalSyncPlugin
from unittest.mock import Mock
import hashlib
# TEI content with processing instruction
tei_content = b"""<?xml version="1.0" encoding="UTF-8"?>
<?xml-model href="http://example.com/schema.rng" type="application/xml" schematypens="http://relaxng.org/ns/structure/1.0"?>
<TEI xmlns="http://www.tei-c.org/ns/1.0">
<teiHeader>
<fileDesc>
<editionStmt>
<edition>
<title>Test Edition</title>
</edition>
</editionStmt>
</fileDesc>
</teiHeader>
<text><body><p>Test content</p></body></text>
</TEI>"""
plugin = LocalSyncPlugin()
# Mock dependencies
mock_file_repo = Mock()
mock_file_storage = Mock()
mock_doc = Mock()
mock_doc.doc_id = "test-doc"
mock_doc.variant = "test"
mock_doc.doc_collections = ["test-collection"]
mock_user = {"username": "testuser", "fullname": "Test User"}
# Track saved content
saved_content = None
# Mock file_storage.save_file to capture content
def mock_save_file(content, file_type, increment_ref=False):
nonlocal saved_content
saved_content = content
content_hash = hashlib.sha256(content).hexdigest()
return content_hash, f"/path/to/{content_hash}"
mock_file_storage.save_file = Mock(side_effect=mock_save_file)
# Mock file_repo.get_latest_tei_version
mock_file_repo.get_latest_tei_version.return_value = None
# Mock file_repo.insert_file to return the created file
def mock_insert_file(file_create):
mock_file = Mock()
mock_file.stable_id = "test-stable-id"
return mock_file
mock_file_repo.insert_file = Mock(side_effect=mock_insert_file)
# Call _create_new_version
result = plugin._create_new_version(
mock_file_repo,
mock_file_storage,
mock_doc,
tei_content,
mock_user
)
# Get the content that was saved
saved_content_str = saved_content.decode('utf-8')
# Verify processing instruction is preserved
self.assertIn('<?xml-model href="http://example.com/schema.rng"', saved_content_str)
self.assertIn('schematypens="http://relaxng.org/ns/structure/1.0"?>', saved_content_str)
if __name__ == '__main__':
unittest.main()