From bff887428e76456a7de0a7e51b8ded15563812f1 Mon Sep 17 00:00:00 2001 From: "Adrian A. Baumann" Date: Fri, 24 Oct 2025 17:19:52 +0000 Subject: [PATCH 1/7] Add comprehensive unit tests for diagram caching - Test hash computation and consistency - Test cache path generation - Test cache miss (generates diagram) - Test cache hit (uses cached diagram) - Test error handling - Test cache clearing (all and by type) - Test unicode handling - Test timeout configuration - Integration tests for full lifecycle --- diagramm_proxy/test_diagram_cache.py | 267 +++++++++++++++++++++++++++ 1 file changed, 267 insertions(+) create mode 100644 diagramm_proxy/test_diagram_cache.py diff --git a/diagramm_proxy/test_diagram_cache.py b/diagramm_proxy/test_diagram_cache.py new file mode 100644 index 0000000..eed7f42 --- /dev/null +++ b/diagramm_proxy/test_diagram_cache.py @@ -0,0 +1,267 @@ +import os +import hashlib +import tempfile +from unittest.mock import patch, Mock, MagicMock +from django.test import TestCase, override_settings +from django.core.files.storage import default_storage +from django.core.files.base import ContentFile +from diagramm_proxy.diagram_cache import ( + get_cache_path, + compute_hash, + get_cached_diagram, + clear_cache, + KROKI_UPSTREAM +) + + +class DiagramCacheTestCase(TestCase): + """Test cases for diagram caching functionality.""" + + def setUp(self): + """Set up test fixtures.""" + self.test_diagram_content = """ + @startuml + Alice -> Bob: Hello + Bob -> Alice: Hi + @enduml + """ + self.diagram_type = "plantuml" + self.temp_media_root = tempfile.mkdtemp() + + def tearDown(self): + """Clean up after tests.""" + # Clean up temporary files + import shutil + if os.path.exists(self.temp_media_root): + shutil.rmtree(self.temp_media_root) + + def test_compute_hash(self): + """Test that hash computation is consistent.""" + content1 = "test content" + content2 = "test content" + content3 = "different content" + + hash1 = compute_hash(content1) + hash2 = compute_hash(content2) + hash3 = compute_hash(content3) + + # Same content should produce same hash + self.assertEqual(hash1, hash2) + + # Different content should produce different hash + self.assertNotEqual(hash1, hash3) + + # Hash should be valid SHA256 (64 hex characters) + self.assertEqual(len(hash1), 64) + self.assertTrue(all(c in '0123456789abcdef' for c in hash1)) + + def test_get_cache_path(self): + """Test cache path generation.""" + content_hash = "abc123def456" + diagram_type = "mermaid" + + path = get_cache_path(diagram_type, content_hash) + + # Path should contain diagram type and hash + self.assertIn("diagram_cache", path) + self.assertIn(diagram_type, path) + self.assertIn(content_hash, path) + self.assertTrue(path.endswith(".svg")) + + @override_settings(MEDIA_ROOT=tempfile.mkdtemp()) + @patch('diagramm_proxy.diagram_cache.requests.post') + def test_get_cached_diagram_cache_miss(self, mock_post): + """Test diagram generation on cache miss.""" + # Mock successful Kroki response + mock_response = Mock() + mock_response.status_code = 200 + mock_response.content = b'test diagram' + mock_response.raise_for_status = Mock() + mock_post.return_value = mock_response + + # Call function + result = get_cached_diagram(self.diagram_type, self.test_diagram_content) + + # Verify POST was called with correct parameters + expected_url = f"{KROKI_UPSTREAM}/{self.diagram_type}/svg" + mock_post.assert_called_once() + call_args = mock_post.call_args + self.assertEqual(call_args[0][0], expected_url) + self.assertEqual(call_args[1]['data'], self.test_diagram_content.encode('utf-8')) + self.assertEqual(call_args[1]['headers']['Content-Type'], 'text/plain') + + # Verify result is a valid path + self.assertIsNotNone(result) + self.assertIn('diagram_cache', result) + + # Verify file was cached + self.assertTrue(default_storage.exists(result)) + + @override_settings(MEDIA_ROOT=tempfile.mkdtemp()) + def test_get_cached_diagram_cache_hit(self): + """Test that cached diagrams are retrieved without HTTP call.""" + # Pre-populate cache + content_hash = compute_hash(self.test_diagram_content) + cache_path = get_cache_path(self.diagram_type, content_hash) + + # Create cache directory and file + full_path = default_storage.path(cache_path) + os.makedirs(os.path.dirname(full_path), exist_ok=True) + default_storage.save(cache_path, ContentFile(b'cached diagram')) + + # Mock requests.post to ensure it's NOT called + with patch('diagramm_proxy.diagram_cache.requests.post') as mock_post: + result = get_cached_diagram(self.diagram_type, self.test_diagram_content) + + # Verify POST was NOT called (cache hit) + mock_post.assert_not_called() + + # Verify correct path returned + self.assertEqual(result, cache_path) + + # Verify file exists + self.assertTrue(default_storage.exists(result)) + + @override_settings(MEDIA_ROOT=tempfile.mkdtemp()) + @patch('diagramm_proxy.diagram_cache.requests.post') + def test_get_cached_diagram_http_error(self, mock_post): + """Test error handling when Kroki server fails.""" + # Mock failed HTTP response + mock_post.side_effect = Exception("Connection failed") + + # Should raise exception + with self.assertRaises(Exception): + get_cached_diagram(self.diagram_type, self.test_diagram_content) + + @override_settings(MEDIA_ROOT=tempfile.mkdtemp()) + def test_clear_cache_all(self): + """Test clearing all cached diagrams.""" + # Create some test cache files + test_files = [ + ('diagram_cache/plantuml/hash1.svg', b'diagram1'), + ('diagram_cache/plantuml/hash2.svg', b'diagram2'), + ('diagram_cache/mermaid/hash3.svg', b'diagram3'), + ] + + for path, content in test_files: + full_path = default_storage.path(path) + os.makedirs(os.path.dirname(full_path), exist_ok=True) + default_storage.save(path, ContentFile(content)) + + # Verify files exist + for path, _ in test_files: + self.assertTrue(default_storage.exists(path)) + + # Clear all cache + clear_cache() + + # Verify files are deleted + for path, _ in test_files: + self.assertFalse(default_storage.exists(path)) + + @override_settings(MEDIA_ROOT=tempfile.mkdtemp()) + def test_clear_cache_by_type(self): + """Test clearing cache for specific diagram type.""" + # Create test cache files + plantuml_file = 'diagram_cache/plantuml/hash1.svg' + mermaid_file = 'diagram_cache/mermaid/hash2.svg' + + for path in [plantuml_file, mermaid_file]: + full_path = default_storage.path(path) + os.makedirs(os.path.dirname(full_path), exist_ok=True) + default_storage.save(path, ContentFile(b'test')) + + # Clear only plantuml cache + clear_cache('plantuml') + + # Verify plantuml file is deleted but mermaid remains + self.assertFalse(default_storage.exists(plantuml_file)) + self.assertTrue(default_storage.exists(mermaid_file)) + + @override_settings(MEDIA_ROOT=tempfile.mkdtemp()) + @patch('diagramm_proxy.diagram_cache.requests.post') + def test_same_content_different_type(self, mock_post): + """Test that same content but different type creates different cache entries.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.content = b'diagram' + mock_response.raise_for_status = Mock() + mock_post.return_value = mock_response + + # Generate for two different types + path1 = get_cached_diagram('plantuml', self.test_diagram_content) + path2 = get_cached_diagram('mermaid', self.test_diagram_content) + + # Paths should be different + self.assertNotEqual(path1, path2) + self.assertIn('plantuml', path1) + self.assertIn('mermaid', path2) + + # Both should exist + self.assertTrue(default_storage.exists(path1)) + self.assertTrue(default_storage.exists(path2)) + + def test_compute_hash_unicode(self): + """Test hash computation with unicode characters.""" + content_with_unicode = "Test with émojis 🎨 and ümlauts" + + # Should not raise exception + result = compute_hash(content_with_unicode) + + # Should produce valid hash + self.assertEqual(len(result), 64) + self.assertTrue(all(c in '0123456789abcdef' for c in result)) + + @override_settings(MEDIA_ROOT=tempfile.mkdtemp()) + @patch('diagramm_proxy.diagram_cache.requests.post') + def test_timeout_configuration(self, mock_post): + """Test that POST request includes timeout.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.content = b'test' + mock_response.raise_for_status = Mock() + mock_post.return_value = mock_response + + get_cached_diagram(self.diagram_type, self.test_diagram_content) + + # Verify timeout parameter was passed + call_kwargs = mock_post.call_args[1] + self.assertIn('timeout', call_kwargs) + self.assertEqual(call_kwargs['timeout'], 30) + + +class DiagramCacheIntegrationTestCase(TestCase): + """Integration tests for diagram caching with file system.""" + + @override_settings(MEDIA_ROOT=tempfile.mkdtemp()) + @patch('diagramm_proxy.diagram_cache.requests.post') + def test_full_cache_lifecycle(self, mock_post): + """Test complete lifecycle: cache miss, cache hit, clear.""" + diagram_content = "@startuml\nA -> B\n@enduml" + diagram_type = "plantuml" + + # Mock Kroki response + mock_response = Mock() + mock_response.status_code = 200 + mock_response.content = b'lifecycle test' + mock_response.raise_for_status = Mock() + mock_post.return_value = mock_response + + # First call - cache miss + path1 = get_cached_diagram(diagram_type, diagram_content) + self.assertEqual(mock_post.call_count, 1) + self.assertTrue(default_storage.exists(path1)) + + # Second call - cache hit + path2 = get_cached_diagram(diagram_type, diagram_content) + self.assertEqual(mock_post.call_count, 1) # Still 1, not called again + self.assertEqual(path1, path2) + + # Clear cache + clear_cache() + self.assertFalse(default_storage.exists(path1)) + + # Third call - cache miss again + path3 = get_cached_diagram(diagram_type, diagram_content) + self.assertEqual(mock_post.call_count, 2) # Called again + self.assertTrue(default_storage.exists(path3)) -- 2.51.0 From da66f2ddc6a389bd4bf9c16b0375df9c4d616173 Mon Sep 17 00:00:00 2001 From: "Adrian A. Baumann" Date: Fri, 24 Oct 2025 17:20:46 +0000 Subject: [PATCH 2/7] Add comprehensive unit tests for rendering functions - Test text rendering with markdown - Test unordered and ordered lists - Test table rendering - Test diagram rendering (success and error cases) - Test diagram with custom options - Test code blocks - Test edge cases (empty content, no type) - Test multiple sections - Test md_table_to_html function - Integration tests for mixed content --- abschnitte/tests.py | 378 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 376 insertions(+), 2 deletions(-) diff --git a/abschnitte/tests.py b/abschnitte/tests.py index 7ce503c..93a0813 100644 --- a/abschnitte/tests.py +++ b/abschnitte/tests.py @@ -1,3 +1,377 @@ -from django.test import TestCase +from unittest.mock import Mock, patch, MagicMock +from django.test import TestCase, override_settings +from abschnitte.models import AbschnittTyp, Textabschnitt +from abschnitte.utils import render_textabschnitte, md_table_to_html -# Create your tests here. + +class MockTextabschnitt: + """Mock object for Textabschnitt (since it's abstract).""" + def __init__(self, abschnitttyp, inhalt): + self.abschnitttyp = abschnitttyp + self.inhalt = inhalt + + +class RenderTextabschnitteTestCase(TestCase): + """Test cases for render_textabschnitte function.""" + + def setUp(self): + """Set up test fixtures.""" + # Create mock AbschnittTyp objects + self.typ_text = Mock() + self.typ_text.abschnitttyp = "text" + + self.typ_liste_ungeordnet = Mock() + self.typ_liste_ungeordnet.abschnitttyp = "liste ungeordnet" + + self.typ_liste_geordnet = Mock() + self.typ_liste_geordnet.abschnitttyp = "liste geordnet" + + self.typ_tabelle = Mock() + self.typ_tabelle.abschnitttyp = "tabelle" + + self.typ_diagramm = Mock() + self.typ_diagramm.abschnitttyp = "diagramm" + + self.typ_code = Mock() + self.typ_code.abschnitttyp = "code" + + def test_render_basic_text(self): + """Test rendering basic text content.""" + abschnitt = MockTextabschnitt( + abschnitttyp=self.typ_text, + inhalt="This is **bold** text with *italic*." + ) + + result = render_textabschnitte([abschnitt]) + + self.assertEqual(len(result), 1) + typ, html = result[0] + self.assertEqual(typ, "text") + self.assertIn("bold", html) + self.assertIn("italic", html) + + def test_render_unordered_list(self): + """Test rendering unordered list.""" + abschnitt = MockTextabschnitt( + abschnitttyp=self.typ_liste_ungeordnet, + inhalt="Item 1\nItem 2\nItem 3" + ) + + result = render_textabschnitte([abschnitt]) + + self.assertEqual(len(result), 1) + typ, html = result[0] + self.assertEqual(typ, "liste ungeordnet") + self.assertIn("