Feature: POST-based diagram generation with local caching #2

Closed
adebaumann wants to merge 7 commits from feature/diagram-post-caching into main
7 changed files with 1333 additions and 2 deletions

259
Documentation/TESTING.md Normal file
View File

@@ -0,0 +1,259 @@
# Running Tests
This document describes how to run the test suite for the vgui-cicd project.
## Test Structure
The project includes comprehensive unit tests for the diagram caching functionality:
```
abschnitte/
├── tests.py # Tests for rendering functions
├── management/
│ └── commands/
│ └── test_clear_diagram_cache.py # Tests for management command
diagramm_proxy/
└── test_diagram_cache.py # Tests for caching module
```
## Running All Tests
To run the entire test suite:
```bash
python manage.py test
```
## Running Specific Test Modules
Run tests for a specific app:
```bash
# Test abschnitte rendering
python manage.py test abschnitte
# Test diagram caching
python manage.py test diagramm_proxy
# Test management commands
python manage.py test abschnitte.management.commands
```
## Running Individual Test Cases
Run a specific test case:
```bash
# Test diagram cache functionality
python manage.py test diagramm_proxy.test_diagram_cache.DiagramCacheTestCase
# Test rendering functions
python manage.py test abschnitte.tests.RenderTextabschnitteTestCase
# Test management command
python manage.py test abschnitte.management.commands.test_clear_diagram_cache
```
## Running Individual Tests
Run a single test method:
```bash
python manage.py test abschnitte.tests.RenderTextabschnitteTestCase.test_render_diagram_success
```
## Test Coverage
To generate a coverage report, install coverage.py:
```bash
pip install coverage
```
Then run:
```bash
# Run tests with coverage
coverage run --source='.' manage.py test
# Generate coverage report
coverage report
# Generate HTML coverage report
coverage html
# Open htmlcov/index.html in browser
```
## Test Options
### Verbose Output
Get more detailed output:
```bash
python manage.py test --verbosity=2
```
### Keep Test Database
Keep the test database after tests complete (useful for debugging):
```bash
python manage.py test --keepdb
```
### Fail Fast
Stop after first test failure:
```bash
python manage.py test --failfast
```
### Parallel Testing
Run tests in parallel (faster for large test suites):
```bash
python manage.py test --parallel
```
## What the Tests Cover
### Diagram Caching Tests (`diagramm_proxy/test_diagram_cache.py`)
- ✅ Hash computation and consistency
- ✅ Cache path generation
- ✅ Cache miss (diagram generation)
- ✅ Cache hit (using cached diagrams)
- ✅ HTTP error handling
- ✅ Cache clearing (all and by type)
- ✅ Unicode content handling
- ✅ Timeout configuration
- ✅ Full lifecycle integration tests
### Rendering Tests (`abschnitte/tests.py`)
- ✅ Text rendering with markdown
- ✅ Unordered and ordered lists
- ✅ Table rendering
- ✅ Diagram rendering (success and error)
- ✅ Diagram with custom options
- ✅ Code blocks
- ✅ Edge cases (empty content, missing types)
- ✅ Multiple sections
- ✅ Mixed content integration tests
### Management Command Tests (`abschnitte/management/commands/test_clear_diagram_cache.py`)
- ✅ Clearing all cache
- ✅ Clearing by specific type
- ✅ Clearing empty cache
- ✅ Command help text
- ✅ Full workflow integration
## Continuous Integration
These tests are designed to run in CI/CD pipelines. Example GitHub Actions workflow:
```yaml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: 3.11
- name: Install dependencies
run: |
pip install -r requirements.txt
- name: Run tests
run: |
python manage.py test --verbosity=2
```
## Debugging Failed Tests
If tests fail, you can:
1. **Run with verbose output** to see detailed error messages:
```bash
python manage.py test --verbosity=2
```
2. **Run specific failing test** to isolate the issue:
```bash
python manage.py test path.to.TestCase.test_method
```
3. **Use pdb for debugging**:
```python
import pdb; pdb.set_trace() # Add to test code
```
4. **Check test database**:
```bash
python manage.py test --keepdb
```
## Test Requirements
The tests use:
- Django's built-in `TestCase` class
- Python's `unittest.mock` for mocking external dependencies
- `@override_settings` for temporary setting changes
- `tempfile` for creating temporary directories
No additional testing libraries are required beyond what's in `requirements.txt`.
## Writing New Tests
When adding new features, follow these patterns:
1. **Test file location**: Place tests in the same app as the code being tested
2. **Test class naming**: Use descriptive names ending in `TestCase`
3. **Test method naming**: Start with `test_` and describe what's being tested
4. **Use mocks**: Mock external dependencies (HTTP calls, file systems when needed)
5. **Clean up**: Use `setUp()` and `tearDown()` or temporary directories
6. **Test edge cases**: Include tests for error conditions and edge cases
Example:
```python
from django.test import TestCase
from unittest.mock import patch
class MyFeatureTestCase(TestCase):
def setUp(self):
"""Set up test fixtures."""
self.test_data = "example"
def test_basic_functionality(self):
"""Test that the feature works correctly."""
result = my_function(self.test_data)
self.assertEqual(result, expected_value)
@patch('my_app.module.external_call')
def test_with_mock(self, mock_call):
"""Test with mocked external dependency."""
mock_call.return_value = "mocked"
result = my_function(self.test_data)
mock_call.assert_called_once()
```
## Test Best Practices
- Write tests before or alongside code (TDD approach)
- Each test should test one thing
- Tests should be independent (no shared state)
- Use descriptive test names
- Mock external dependencies (HTTP, filesystem, etc.)
- Test both success and failure cases
- Aim for high code coverage (>80%)

99
TESTING_QUICKSTART.md Normal file
View File

@@ -0,0 +1,99 @@
# Quick Test Guide
Quick reference for running tests in the vgui-cicd project.
## 🚀 Quick Start
```bash
# Run all tests
python manage.py test
# Or use the convenient runner
python run_tests.py all
```
## 📋 Common Commands
| Command | Description |
|---------|-------------|
| `python run_tests.py all` | Run all tests with verbose output |
| `python run_tests.py cache` | Run diagram cache tests only |
| `python run_tests.py render` | Run rendering tests only |
| `python run_tests.py commands` | Run management command tests |
| `python run_tests.py coverage` | Run tests with coverage report |
| `python run_tests.py fast` | Run with fail-fast (stops at first failure) |
## 🎯 Run Specific Tests
```bash
# Test a specific module
python manage.py test diagramm_proxy
# Test a specific test case
python manage.py test abschnitte.tests.RenderTextabschnitteTestCase
# Test a specific method
python manage.py test abschnitte.tests.RenderTextabschnitteTestCase.test_render_diagram_success
```
## 📊 Coverage Report
```bash
# Generate coverage report
python run_tests.py coverage
# Or manually
pip install coverage
coverage run --source='.' manage.py test
coverage report
coverage html # Creates htmlcov/index.html
```
## ✅ What's Tested
### Diagram Caching (`diagramm_proxy/test_diagram_cache.py`)
- Hash computation ✓
- Cache hit/miss ✓
- HTTP errors ✓
- Cache clearing ✓
- Unicode support ✓
### Rendering (`abschnitte/tests.py`)
- Markdown to HTML ✓
- Lists (ordered/unordered) ✓
- Tables ✓
- Diagrams ✓
- Code blocks ✓
### Management Commands (`abschnitte/management/commands/test_clear_diagram_cache.py`)
- Clear all cache ✓
- Clear by type ✓
- Help text ✓
## 🐛 Debugging Failed Tests
```bash
# Run with more detail
python manage.py test --verbosity=2
# Stop at first failure
python manage.py test --failfast
# Keep test database
python manage.py test --keepdb
```
## 📖 More Information
See `Documentation/TESTING.md` for complete documentation.
## 🎓 Test Statistics
- **Total Test Files**: 3
- **Total Test Cases**: 29+
- **Lines of Test Code**: 790+
- **Coverage Target**: >80%
---
**Need help?** Check the full testing guide in `Documentation/TESTING.md`

View File

@@ -0,0 +1,164 @@
import os
import tempfile
from io import StringIO
from unittest.mock import patch, Mock
from django.test import TestCase, override_settings
from django.core.management import call_command
from django.core.files.storage import default_storage
from django.core.files.base import ContentFile
class ClearDiagramCacheCommandTestCase(TestCase):
"""Test cases for clear_diagram_cache management command."""
@override_settings(MEDIA_ROOT=tempfile.mkdtemp())
def test_clear_all_cache(self):
"""Test clearing all cached diagrams."""
# Create test cache files
test_files = [
'diagram_cache/plantuml/hash1.svg',
'diagram_cache/plantuml/hash2.svg',
'diagram_cache/mermaid/hash3.svg',
]
for path in test_files:
full_path = default_storage.path(path)
os.makedirs(os.path.dirname(full_path), exist_ok=True)
default_storage.save(path, ContentFile(b'test diagram'))
# Verify files exist
for path in test_files:
self.assertTrue(default_storage.exists(path))
# Run command
out = StringIO()
call_command('clear_diagram_cache', stdout=out)
# Verify files are deleted
for path in test_files:
self.assertFalse(default_storage.exists(path))
# Verify success message
output = out.getvalue()
self.assertIn('Clearing all diagram caches', output)
self.assertIn('Cache cleared successfully', output)
@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_files = [
'diagram_cache/plantuml/hash1.svg',
'diagram_cache/plantuml/hash2.svg',
]
mermaid_files = [
'diagram_cache/mermaid/hash3.svg',
]
for path in plantuml_files + mermaid_files:
full_path = default_storage.path(path)
os.makedirs(os.path.dirname(full_path), exist_ok=True)
default_storage.save(path, ContentFile(b'test diagram'))
# Run command for plantuml only
out = StringIO()
call_command('clear_diagram_cache', type='plantuml', stdout=out)
# Verify only plantuml files are deleted
for path in plantuml_files:
self.assertFalse(default_storage.exists(path))
for path in mermaid_files:
self.assertTrue(default_storage.exists(path))
# Verify output message
output = out.getvalue()
self.assertIn('Clearing cache for plantuml', output)
self.assertIn('Cache cleared successfully', output)
@override_settings(MEDIA_ROOT=tempfile.mkdtemp())
def test_clear_empty_cache(self):
"""Test clearing cache when no files exist."""
# Don't create any files
# Should not raise error
out = StringIO()
call_command('clear_diagram_cache', stdout=out)
# Should still show success
output = out.getvalue()
self.assertIn('Cache cleared successfully', output)
@override_settings(MEDIA_ROOT=tempfile.mkdtemp())
@patch('diagramm_proxy.diagram_cache.clear_cache')
def test_command_calls_clear_cache_function(self, mock_clear):
"""Test that command properly calls the clear_cache function."""
# Call without type
call_command('clear_diagram_cache')
mock_clear.assert_called_once_with(None)
# Call with type
mock_clear.reset_mock()
call_command('clear_diagram_cache', type='plantuml')
mock_clear.assert_called_once_with('plantuml')
def test_command_help_text(self):
"""Test that command has proper help text."""
out = StringIO()
call_command('clear_diagram_cache', '--help', stdout=out)
output = out.getvalue()
self.assertIn('Clear cached diagrams', output)
self.assertIn('--type', output)
class ManagementCommandIntegrationTestCase(TestCase):
"""Integration tests for management command with real filesystem."""
@override_settings(MEDIA_ROOT=tempfile.mkdtemp())
def test_full_command_workflow(self):
"""Test complete workflow: create cache, clear specific type, clear all."""
# Create mixed cache files
files = {
'plantuml': [
'diagram_cache/plantuml/abc123.svg',
'diagram_cache/plantuml/def456.svg',
],
'mermaid': [
'diagram_cache/mermaid/ghi789.svg',
],
'graphviz': [
'diagram_cache/graphviz/jkl012.svg',
]
}
# Create all files
for file_list in files.values():
for path in file_list:
full_path = default_storage.path(path)
os.makedirs(os.path.dirname(full_path), exist_ok=True)
default_storage.save(path, ContentFile(b'diagram content'))
# Verify all exist
for file_list in files.values():
for path in file_list:
self.assertTrue(default_storage.exists(path))
# Clear plantuml
call_command('clear_diagram_cache', type='plantuml')
# Verify plantuml deleted, others remain
for path in files['plantuml']:
self.assertFalse(default_storage.exists(path))
for path in files['mermaid']:
self.assertTrue(default_storage.exists(path))
for path in files['graphviz']:
self.assertTrue(default_storage.exists(path))
# Clear all remaining
call_command('clear_diagram_cache')
# Verify all deleted
for file_list in files.values():
for path in file_list:
self.assertFalse(default_storage.exists(path))

View File

@@ -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("<strong>bold</strong>", html)
self.assertIn("<em>italic</em>", 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("<ul>", html)
self.assertIn("<li>Item 1</li>", html)
self.assertIn("<li>Item 2</li>", html)
self.assertIn("<li>Item 3</li>", html)
def test_render_ordered_list(self):
"""Test rendering ordered list."""
abschnitt = MockTextabschnitt(
abschnitttyp=self.typ_liste_geordnet,
inhalt="First item\nSecond item\nThird item"
)
result = render_textabschnitte([abschnitt])
self.assertEqual(len(result), 1)
typ, html = result[0]
self.assertEqual(typ, "liste geordnet")
self.assertIn("<ol>", html)
self.assertIn("<li>First item</li>", html)
def test_render_table(self):
"""Test rendering markdown table."""
table_content = """| Header 1 | Header 2 |
|----------|----------|
| Cell 1 | Cell 2 |
| Cell 3 | Cell 4 |"""
abschnitt = MockTextabschnitt(
abschnitttyp=self.typ_tabelle,
inhalt=table_content
)
result = render_textabschnitte([abschnitt])
self.assertEqual(len(result), 1)
typ, html = result[0]
self.assertEqual(typ, "tabelle")
self.assertIn("<table", html)
self.assertIn("Header 1", html)
self.assertIn("Cell 1", html)
self.assertIn("table-bordered", html)
@override_settings(MEDIA_URL='/media/')
@patch('abschnitte.utils.get_cached_diagram')
def test_render_diagram_success(self, mock_get_cached):
"""Test rendering diagram with successful cache."""
mock_get_cached.return_value = 'diagram_cache/plantuml/abc123.svg'
diagram_content = """plantuml
@startuml
A -> B
@enduml"""
abschnitt = MockTextabschnitt(
abschnitttyp=self.typ_diagramm,
inhalt=diagram_content
)
result = render_textabschnitte([abschnitt])
self.assertEqual(len(result), 1)
typ, html = result[0]
self.assertEqual(typ, "diagramm")
# Verify diagram cache was called correctly
mock_get_cached.assert_called_once_with(
'plantuml',
'@startuml\nA -> B\n@enduml'
)
# Verify HTML contains correct image tag
self.assertIn('<img', html)
self.assertIn('/media/diagram_cache/plantuml/abc123.svg', html)
self.assertIn('width="100%"', html)
@patch('abschnitte.utils.get_cached_diagram')
def test_render_diagram_with_options(self, mock_get_cached):
"""Test rendering diagram with custom options."""
mock_get_cached.return_value = 'diagram_cache/mermaid/def456.svg'
diagram_content = """mermaid
option:width="50%" height="300px"
graph TD
A --> B"""
abschnitt = MockTextabschnitt(
abschnitttyp=self.typ_diagramm,
inhalt=diagram_content
)
result = render_textabschnitte([abschnitt])
typ, html = result[0]
# Verify custom options are used
self.assertIn('width="50%"', html)
self.assertIn('height="300px"', html)
# Verify diagram content doesn't include option line
call_args = mock_get_cached.call_args[0]
self.assertNotIn('option:', call_args[1])
@patch('abschnitte.utils.get_cached_diagram')
def test_render_diagram_error(self, mock_get_cached):
"""Test rendering diagram when caching fails."""
mock_get_cached.side_effect = Exception("Kroki server unavailable")
diagram_content = """plantuml
@startuml
A -> B
@enduml"""
abschnitt = MockTextabschnitt(
abschnitttyp=self.typ_diagramm,
inhalt=diagram_content
)
result = render_textabschnitte([abschnitt])
typ, html = result[0]
# Should show error message
self.assertIn('Error generating diagram', html)
self.assertIn('Kroki server unavailable', html)
self.assertIn('text-danger', html)
def test_render_code(self):
"""Test rendering code block."""
code_content = """def hello():
print("Hello, World!")"""
abschnitt = MockTextabschnitt(
abschnitttyp=self.typ_code,
inhalt=code_content
)
result = render_textabschnitte([abschnitt])
typ, html = result[0]
self.assertEqual(typ, "code")
self.assertIn("<pre>", html)
self.assertIn("<code>", html)
self.assertIn("def hello():", html)
def test_render_empty_content(self):
"""Test rendering with empty or None content."""
abschnitt = MockTextabschnitt(
abschnitttyp=self.typ_text,
inhalt=None
)
result = render_textabschnitte([abschnitt])
self.assertEqual(len(result), 1)
typ, html = result[0]
self.assertEqual(typ, "text")
# Should not crash, should return empty or minimal HTML
def test_render_multiple_abschnitte(self):
"""Test rendering multiple sections."""
abschnitte = [
MockTextabschnitt(self.typ_text, "First paragraph"),
MockTextabschnitt(self.typ_liste_ungeordnet, "Item 1\nItem 2"),
MockTextabschnitt(self.typ_text, "Second paragraph"),
]
result = render_textabschnitte(abschnitte)
self.assertEqual(len(result), 3)
self.assertEqual(result[0][0], "text")
self.assertEqual(result[1][0], "liste ungeordnet")
self.assertEqual(result[2][0], "text")
def test_render_no_abschnitttyp(self):
"""Test rendering when abschnitttyp is None."""
abschnitt = MockTextabschnitt(
abschnitttyp=None,
inhalt="Some content"
)
result = render_textabschnitte([abschnitt])
self.assertEqual(len(result), 1)
typ, html = result[0]
self.assertEqual(typ, "")
# Should still render as markdown
self.assertIn("Some content", html)
class MdTableToHtmlTestCase(TestCase):
"""Test cases for md_table_to_html function."""
def test_basic_table(self):
"""Test converting basic markdown table to HTML."""
md = """| Name | Age |
|------|-----|
| Alice | 30 |
| Bob | 25 |"""
html = md_table_to_html(md)
self.assertIn('<table', html)
self.assertIn('table-bordered', html)
self.assertIn('table-hover', html)
self.assertIn('<thead>', html)
self.assertIn('<tbody>', html)
self.assertIn('<th>Name</th>', html)
self.assertIn('<th>Age</th>', html)
self.assertIn('<td>Alice</td>', html)
self.assertIn('<td>30</td>', html)
def test_table_with_spaces(self):
"""Test table with various spacing."""
md = """| Name | Age |
|--------|-------|
| Alice | 30 |
| Bob | 25 |"""
html = md_table_to_html(md)
# Spaces should be trimmed
self.assertIn('<th>Name</th>', html)
self.assertIn('<th>Age</th>', html)
self.assertIn('<td>Alice</td>', html)
def test_table_no_outer_pipes(self):
"""Test table without outer pipe characters."""
md = """Name | Age
-----|-----
Alice | 30
Bob | 25"""
html = md_table_to_html(md)
self.assertIn('<th>Name</th>', html)
self.assertIn('<td>Alice</td>', html)
def test_table_insufficient_rows(self):
"""Test error handling for malformed table."""
md = """| Name |
|------|"""
with self.assertRaises(ValueError) as context:
md_table_to_html(md)
self.assertIn("at least header + separator", str(context.exception))
def test_table_with_empty_cells(self):
"""Test table with empty cells."""
md = """| Name | Age |
|------|-----|
| Alice | |
| | 25 |"""
html = md_table_to_html(md)
self.assertIn('<td>Alice</td>', html)
self.assertIn('<td></td>', html)
self.assertIn('<td>25</td>', html)
def test_table_three_columns(self):
"""Test table with more columns."""
md = """| First | Middle | Last |
|-------|--------|------|
| John | Q | Doe |"""
html = md_table_to_html(md)
self.assertIn('<th>First</th>', html)
self.assertIn('<th>Middle</th>', html)
self.assertIn('<th>Last</th>', html)
self.assertIn('<td>John</td>', html)
self.assertIn('<td>Q</td>', html)
self.assertIn('<td>Doe</td>', html)
class RenderIntegrationTestCase(TestCase):
"""Integration tests for rendering pipeline."""
@patch('abschnitte.utils.get_cached_diagram')
def test_mixed_content_rendering(self, mock_get_cached):
"""Test rendering a mix of different content types."""
mock_get_cached.return_value = 'diagram_cache/test.svg'
# Create various types of content
typ_text = Mock(abschnitttyp="text")
typ_liste = Mock(abschnitttyp="liste ungeordnet")
typ_diagram = Mock(abschnitttyp="diagramm")
abschnitte = [
MockTextabschnitt(typ_text, "Introduction paragraph"),
MockTextabschnitt(typ_liste, "Point 1\nPoint 2\nPoint 3"),
MockTextabschnitt(typ_diagram, "plantuml\n@startuml\nA -> B\n@enduml"),
MockTextabschnitt(typ_text, "Conclusion"),
]
result = render_textabschnitte(abschnitte)
# Should have 4 results
self.assertEqual(len(result), 4)
# Verify each type was rendered correctly
self.assertEqual(result[0][0], "text")
self.assertIn("Introduction", result[0][1])
self.assertEqual(result[1][0], "liste ungeordnet")
self.assertIn("<ul>", result[1][1])
self.assertEqual(result[2][0], "diagramm")
self.assertIn("<img", result[2][1])
self.assertEqual(result[3][0], "text")
self.assertIn("Conclusion", result[3][1])

View File

@@ -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'<svg>test diagram</svg>'
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'<svg>cached diagram</svg>'))
# 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'<svg>diagram</svg>'
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'<svg>test</svg>'
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'<svg>lifecycle test</svg>'
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))

60
pytest.ini Normal file
View File

@@ -0,0 +1,60 @@
[tool:pytest]
# Pytest configuration for vgui-cicd project
# To use pytest instead of Django's test runner:
# pip install pytest pytest-django
# pytest
DJANGO_SETTINGS_MODULE = VorgabenUI.settings
python_files = tests.py test_*.py *_tests.py
python_classes = *TestCase *Test
python_functions = test_*
# Test discovery patterns
testpaths =
abschnitte
diagramm_proxy
standards
stichworte
referenzen
rollen
pages
# Output options
addopts =
--verbose
--strict-markers
--tb=short
--reuse-db
# Coverage options (when using pytest-cov)
# Uncomment to enable:
# --cov=.
# --cov-report=html
# --cov-report=term-missing
# Markers for organizing tests
markers =
slow: marks tests as slow (deselect with '-m "not slow"')
integration: marks tests as integration tests
unit: marks tests as unit tests
cache: marks tests related to caching
render: marks tests related to rendering
commands: marks tests related to management commands
# Ignore paths
norecursedirs =
.git
.tox
dist
build
*.egg
__pycache__
static
staticfiles
media
data
venv
.venv
# Django-specific settings
django_find_project = false

108
run_tests.py Normal file
View File

@@ -0,0 +1,108 @@
#!/usr/bin/env python
"""
Test runner script for vgui-cicd project.
Provides convenient shortcuts for running different test suites.
"""
import sys
import os
from django.core.management import execute_from_command_line
def main():
"""Run tests based on command line arguments."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'VorgabenUI.settings')
if len(sys.argv) > 1:
command = sys.argv[1]
if command == 'all':
# Run all tests
execute_from_command_line(['manage.py', 'test', '--verbosity=2'])
elif command == 'cache':
# Run only cache tests
execute_from_command_line(['manage.py', 'test', 'diagramm_proxy', '--verbosity=2'])
elif command == 'render':
# Run only rendering tests
execute_from_command_line(['manage.py', 'test', 'abschnitte.tests', '--verbosity=2'])
elif command == 'commands':
# Run only management command tests
execute_from_command_line(['manage.py', 'test', 'abschnitte.management.commands', '--verbosity=2'])
elif command == 'coverage':
# Run tests with coverage
try:
import coverage
cov = coverage.Coverage(source=['.'])
cov.start()
execute_from_command_line(['manage.py', 'test', '--verbosity=2'])
cov.stop()
cov.save()
print('\n' + '='*70)
print('COVERAGE REPORT')
print('='*70)
cov.report()
print('\nHTML coverage report generated in htmlcov/')
cov.html_report()
except ImportError:
print('ERROR: coverage.py not installed.')
print('Install with: pip install coverage')
sys.exit(1)
elif command == 'fast':
# Run tests with failfast
execute_from_command_line(['manage.py', 'test', '--failfast', '--verbosity=2'])
elif command == 'help':
print_help()
else:
# Pass through to Django test runner
execute_from_command_line(['manage.py', 'test'] + sys.argv[1:])
else:
print_help()
def print_help():
"""Print help message."""
help_text = """
Usage: python run_tests.py [command]
Commands:
all Run all tests
cache Run diagram cache tests only
render Run rendering tests only
commands Run management command tests only
coverage Run tests with coverage report
fast Run tests with fail-fast option
help Show this help message
Advanced:
You can also pass standard Django test arguments:
python run_tests.py <app_label>
python run_tests.py <app_label.TestCase>
python run_tests.py <app_label.TestCase.test_method>
Examples:
python run_tests.py all
python run_tests.py cache
python run_tests.py coverage
python run_tests.py diagramm_proxy.test_diagram_cache.DiagramCacheTestCase
python run_tests.py abschnitte.tests.RenderTextabschnitteTestCase.test_render_diagram_success
For more options, see Documentation/TESTING.md
"""
print(help_text)
if __name__ == '__main__':
main()