570 lines
22 KiB
Python
570 lines
22 KiB
Python
# -*- coding: utf-8 -*-
|
|
|
|
import unittest
|
|
import os
|
|
import re
|
|
import tempfile
|
|
from io import BytesIO
|
|
from docx import Document
|
|
from hypothesis import given, settings, HealthCheck
|
|
|
|
from odoo.addons.survey_custom_certificate_template.services.certificate_generator import (
|
|
CertificateGenerator,
|
|
)
|
|
from odoo.addons.survey_custom_certificate_template.tests.hypothesis_strategies import (
|
|
docx_with_mappings_and_data,
|
|
)
|
|
|
|
|
|
class TestCertificateGenerator(unittest.TestCase):
|
|
"""Test cases for CertificateGenerator service"""
|
|
|
|
def setUp(self):
|
|
"""Set up test fixtures"""
|
|
self.generator = CertificateGenerator()
|
|
|
|
def _create_test_docx(self, text_content):
|
|
"""
|
|
Helper method to create a DOCX file with given text content.
|
|
|
|
Args:
|
|
text_content: String or list of strings to add as paragraphs
|
|
|
|
Returns:
|
|
bytes: Binary content of the created DOCX file
|
|
"""
|
|
doc = Document()
|
|
|
|
if isinstance(text_content, str):
|
|
text_content = [text_content]
|
|
|
|
for text in text_content:
|
|
doc.add_paragraph(text)
|
|
|
|
# Save to BytesIO
|
|
doc_stream = BytesIO()
|
|
doc.save(doc_stream)
|
|
doc_stream.seek(0)
|
|
return doc_stream.read()
|
|
|
|
def test_build_replacement_dict_with_custom_text(self):
|
|
"""Test building replacement dictionary with custom text"""
|
|
mappings = {
|
|
'placeholders': [
|
|
{
|
|
'key': '{key.name}',
|
|
'value_type': 'custom_text',
|
|
'custom_text': 'John Doe'
|
|
}
|
|
]
|
|
}
|
|
data = {}
|
|
|
|
replacements = self.generator._build_replacement_dict(mappings, data)
|
|
|
|
self.assertEqual(replacements, {'{key.name}': 'John Doe'})
|
|
|
|
def test_build_replacement_dict_with_dynamic_data(self):
|
|
"""Test building replacement dictionary with dynamic data"""
|
|
mappings = {
|
|
'placeholders': [
|
|
{
|
|
'key': '{key.name}',
|
|
'value_type': 'user_field',
|
|
'value_field': 'partner_name'
|
|
},
|
|
{
|
|
'key': '{key.course}',
|
|
'value_type': 'survey_field',
|
|
'value_field': 'survey_title'
|
|
}
|
|
]
|
|
}
|
|
data = {
|
|
'partner_name': 'Jane Smith',
|
|
'survey_title': 'Python Programming'
|
|
}
|
|
|
|
replacements = self.generator._build_replacement_dict(mappings, data)
|
|
|
|
expected = {
|
|
'{key.name}': 'Jane Smith',
|
|
'{key.course}': 'Python Programming'
|
|
}
|
|
self.assertEqual(replacements, expected)
|
|
|
|
def test_build_replacement_dict_with_missing_data(self):
|
|
"""Test that missing data results in empty string"""
|
|
mappings = {
|
|
'placeholders': [
|
|
{
|
|
'key': '{key.name}',
|
|
'value_type': 'user_field',
|
|
'value_field': 'partner_name'
|
|
}
|
|
]
|
|
}
|
|
data = {} # No data provided
|
|
|
|
replacements = self.generator._build_replacement_dict(mappings, data)
|
|
|
|
self.assertEqual(replacements, {'{key.name}': ''})
|
|
|
|
def test_replace_placeholders_in_paragraph(self):
|
|
"""Test replacing placeholders in a simple paragraph"""
|
|
# Create a document with placeholders
|
|
doc = Document()
|
|
doc.add_paragraph("Hello {key.name}, welcome to {key.course}!")
|
|
|
|
mappings = {
|
|
'placeholders': [
|
|
{'key': '{key.name}', 'value_type': 'custom_text', 'custom_text': 'Alice'},
|
|
{'key': '{key.course}', 'value_type': 'custom_text', 'custom_text': 'Data Science'}
|
|
]
|
|
}
|
|
data = {}
|
|
|
|
# Replace placeholders
|
|
result_doc = self.generator.replace_placeholders(doc, mappings, data)
|
|
|
|
# Check the result
|
|
result_text = result_doc.paragraphs[0].text
|
|
self.assertEqual(result_text, "Hello Alice, welcome to Data Science!")
|
|
|
|
def test_replace_placeholders_in_table(self):
|
|
"""Test replacing placeholders in tables"""
|
|
# Create a document with a table containing placeholders
|
|
doc = Document()
|
|
table = doc.add_table(rows=2, cols=2)
|
|
table.cell(0, 0).text = "Name: {key.name}"
|
|
table.cell(0, 1).text = "Course: {key.course}"
|
|
table.cell(1, 0).text = "Date: {key.date}"
|
|
table.cell(1, 1).text = "Score: {key.score}"
|
|
|
|
mappings = {
|
|
'placeholders': [
|
|
{'key': '{key.name}', 'value_type': 'custom_text', 'custom_text': 'Bob'},
|
|
{'key': '{key.course}', 'value_type': 'custom_text', 'custom_text': 'Math'},
|
|
{'key': '{key.date}', 'value_type': 'custom_text', 'custom_text': '2024-01-15'},
|
|
{'key': '{key.score}', 'value_type': 'custom_text', 'custom_text': '95'}
|
|
]
|
|
}
|
|
data = {}
|
|
|
|
# Replace placeholders
|
|
result_doc = self.generator.replace_placeholders(doc, mappings, data)
|
|
|
|
# Check the results
|
|
result_table = result_doc.tables[0]
|
|
self.assertEqual(result_table.cell(0, 0).text, "Name: Bob")
|
|
self.assertEqual(result_table.cell(0, 1).text, "Course: Math")
|
|
self.assertEqual(result_table.cell(1, 0).text, "Date: 2024-01-15")
|
|
self.assertEqual(result_table.cell(1, 1).text, "Score: 95")
|
|
|
|
def test_replace_placeholders_no_change_when_no_placeholders(self):
|
|
"""Test that documents without placeholders remain unchanged"""
|
|
# Create a document without placeholders
|
|
doc = Document()
|
|
original_text = "This is a static certificate"
|
|
doc.add_paragraph(original_text)
|
|
|
|
mappings = {'placeholders': []}
|
|
data = {}
|
|
|
|
# Replace placeholders (should do nothing)
|
|
result_doc = self.generator.replace_placeholders(doc, mappings, data)
|
|
|
|
# Check the result
|
|
result_text = result_doc.paragraphs[0].text
|
|
self.assertEqual(result_text, original_text)
|
|
|
|
def test_generate_certificate_validates_inputs(self):
|
|
"""Test that generate_certificate validates required inputs"""
|
|
# Test with empty template
|
|
with self.assertRaises(ValueError) as context:
|
|
self.generator.generate_certificate(b"", {}, {})
|
|
self.assertIn("Template binary data is required", str(context.exception))
|
|
|
|
# Test with non-bytes template
|
|
with self.assertRaises(ValueError) as context:
|
|
self.generator.generate_certificate("not bytes", {}, {})
|
|
self.assertIn("Template must be provided as binary data", str(context.exception))
|
|
|
|
# Test with missing mappings
|
|
template = self._create_test_docx("Test")
|
|
with self.assertRaises(ValueError) as context:
|
|
self.generator.generate_certificate(template, {}, {})
|
|
self.assertIn("Mappings must contain 'placeholders' key", str(context.exception))
|
|
|
|
# Test with missing data
|
|
with self.assertRaises(ValueError) as context:
|
|
self.generator.generate_certificate(template, {'placeholders': []}, None)
|
|
self.assertIn("Data dictionary is required", str(context.exception))
|
|
|
|
def test_generate_certificate_handles_invalid_template(self):
|
|
"""Test that generate_certificate handles invalid DOCX files"""
|
|
invalid_template = b"This is not a valid DOCX file"
|
|
mappings = {'placeholders': []}
|
|
data = {}
|
|
|
|
with self.assertRaises(ValueError) as context:
|
|
self.generator.generate_certificate(invalid_template, mappings, data)
|
|
self.assertIn("not a valid DOCX file", str(context.exception))
|
|
|
|
def test_convert_to_pdf_validates_file_exists(self):
|
|
"""Test that convert_to_pdf validates file existence"""
|
|
non_existent_path = "/tmp/non_existent_file_12345.docx"
|
|
|
|
with self.assertRaises(ValueError) as context:
|
|
self.generator.convert_to_pdf(non_existent_path)
|
|
self.assertIn("DOCX file not found", str(context.exception))
|
|
|
|
def test_replace_placeholders_with_headers_and_footers(self):
|
|
"""Test replacing placeholders in headers and footers"""
|
|
# Create a document with header and footer
|
|
doc = Document()
|
|
doc.add_paragraph("Body: {key.body}")
|
|
|
|
# Add header
|
|
section = doc.sections[0]
|
|
header = section.header
|
|
header.paragraphs[0].text = "Header: {key.header}"
|
|
|
|
# Add footer
|
|
footer = section.footer
|
|
footer.paragraphs[0].text = "Footer: {key.footer}"
|
|
|
|
mappings = {
|
|
'placeholders': [
|
|
{'key': '{key.body}', 'value_type': 'custom_text', 'custom_text': 'Body Text'},
|
|
{'key': '{key.header}', 'value_type': 'custom_text', 'custom_text': 'Header Text'},
|
|
{'key': '{key.footer}', 'value_type': 'custom_text', 'custom_text': 'Footer Text'}
|
|
]
|
|
}
|
|
data = {}
|
|
|
|
# Replace placeholders
|
|
result_doc = self.generator.replace_placeholders(doc, mappings, data)
|
|
|
|
# Check the results
|
|
self.assertEqual(result_doc.paragraphs[0].text, "Body: Body Text")
|
|
self.assertEqual(result_doc.sections[0].header.paragraphs[0].text, "Header: Header Text")
|
|
self.assertEqual(result_doc.sections[0].footer.paragraphs[0].text, "Footer: Footer Text")
|
|
|
|
def test_libreoffice_availability_check(self):
|
|
"""Test LibreOffice availability checking"""
|
|
# Reset the cached check to ensure fresh test
|
|
CertificateGenerator.reset_libreoffice_check()
|
|
|
|
# Check LibreOffice availability
|
|
is_available, error_message = CertificateGenerator.check_libreoffice_availability()
|
|
|
|
# The result should be consistent
|
|
self.assertIsInstance(is_available, bool)
|
|
self.assertIsInstance(error_message, str)
|
|
|
|
# If not available, error message should not be empty
|
|
if not is_available:
|
|
self.assertTrue(len(error_message) > 0)
|
|
else:
|
|
# If available, error message should be empty
|
|
self.assertEqual(error_message, '')
|
|
|
|
# Second call should return cached result
|
|
is_available_2, error_message_2 = CertificateGenerator.check_libreoffice_availability()
|
|
self.assertEqual(is_available, is_available_2)
|
|
self.assertEqual(error_message, error_message_2)
|
|
|
|
def test_convert_to_pdf_handles_missing_libreoffice(self):
|
|
"""Test that convert_to_pdf handles missing LibreOffice gracefully"""
|
|
# Create a temporary DOCX file
|
|
template = self._create_test_docx("Test certificate content")
|
|
temp_docx = tempfile.NamedTemporaryFile(suffix='.docx', delete=False)
|
|
temp_docx_path = temp_docx.name
|
|
temp_docx.write(template)
|
|
temp_docx.close()
|
|
|
|
try:
|
|
# Save the original LibreOffice availability state
|
|
original_available = CertificateGenerator._libreoffice_available
|
|
original_error = CertificateGenerator._libreoffice_check_error
|
|
|
|
# Simulate LibreOffice not being available
|
|
CertificateGenerator._libreoffice_available = False
|
|
CertificateGenerator._libreoffice_check_error = "LibreOffice not found (simulated)"
|
|
|
|
# Attempt to convert to PDF
|
|
with self.assertRaises(RuntimeError) as context:
|
|
self.generator.convert_to_pdf(temp_docx_path)
|
|
|
|
# Verify the error message mentions LibreOffice
|
|
self.assertIn("LibreOffice", str(context.exception))
|
|
self.assertIn("not available", str(context.exception).lower())
|
|
|
|
# Restore the original state
|
|
CertificateGenerator._libreoffice_available = original_available
|
|
CertificateGenerator._libreoffice_check_error = original_error
|
|
|
|
finally:
|
|
# Clean up temporary file
|
|
if os.path.exists(temp_docx_path):
|
|
os.unlink(temp_docx_path)
|
|
|
|
def test_generate_certificate_end_to_end_without_pdf(self):
|
|
"""Test certificate generation without PDF conversion (DOCX only)"""
|
|
# Create a template with placeholders
|
|
template = self._create_test_docx([
|
|
"Certificate of Completion",
|
|
"This certifies that {key.name} has completed {key.course}",
|
|
"Date: {key.date}"
|
|
])
|
|
|
|
mappings = {
|
|
'placeholders': [
|
|
{'key': '{key.name}', 'value_type': 'custom_text', 'custom_text': 'John Doe'},
|
|
{'key': '{key.course}', 'value_type': 'user_field', 'value_field': 'course_title'},
|
|
{'key': '{key.date}', 'value_type': 'custom_text', 'custom_text': '2024-01-15'}
|
|
]
|
|
}
|
|
|
|
data = {
|
|
'course_title': 'Advanced Python Programming'
|
|
}
|
|
|
|
# Test that the method validates inputs and processes the template
|
|
# Note: We're not testing PDF conversion here as it requires LibreOffice
|
|
# Instead, we test the DOCX processing part
|
|
try:
|
|
# This will fail at PDF conversion if LibreOffice is not available
|
|
# but should succeed in processing the DOCX part
|
|
result = self.generator.generate_certificate(template, mappings, data)
|
|
|
|
# If we get here, LibreOffice is available and conversion succeeded
|
|
self.assertIsInstance(result, bytes)
|
|
self.assertTrue(len(result) > 0)
|
|
|
|
except RuntimeError as e:
|
|
# If LibreOffice is not available, that's expected
|
|
if "LibreOffice" in str(e) or "PDF conversion" in str(e):
|
|
# This is expected when LibreOffice is not installed
|
|
pass
|
|
else:
|
|
# Re-raise if it's a different error
|
|
raise
|
|
|
|
def test_data_retrieval_with_nested_fields(self):
|
|
"""Test data retrieval from nested field paths"""
|
|
mappings = {
|
|
'placeholders': [
|
|
{
|
|
'key': '{key.partner_name}',
|
|
'value_type': 'user_field',
|
|
'value_field': 'partner.name'
|
|
},
|
|
{
|
|
'key': '{key.partner_email}',
|
|
'value_type': 'user_field',
|
|
'value_field': 'partner.email'
|
|
}
|
|
]
|
|
}
|
|
|
|
# Simulate nested data structure
|
|
data = {
|
|
'partner.name': 'Alice Johnson',
|
|
'partner.email': 'alice@example.com'
|
|
}
|
|
|
|
replacements = self.generator._build_replacement_dict(mappings, data)
|
|
|
|
expected = {
|
|
'{key.partner_name}': 'Alice Johnson',
|
|
'{key.partner_email}': 'alice@example.com'
|
|
}
|
|
self.assertEqual(replacements, expected)
|
|
|
|
def test_error_handling_for_malformed_mappings(self):
|
|
"""Test that malformed mappings are handled gracefully"""
|
|
# Test with missing 'key' field
|
|
mappings = {
|
|
'placeholders': [
|
|
{
|
|
'value_type': 'custom_text',
|
|
'custom_text': 'Some Value'
|
|
}
|
|
]
|
|
}
|
|
data = {}
|
|
|
|
# Should not raise an exception, just skip the malformed mapping
|
|
replacements = self.generator._build_replacement_dict(mappings, data)
|
|
self.assertEqual(replacements, {})
|
|
|
|
# Test with missing 'value_type' field
|
|
mappings = {
|
|
'placeholders': [
|
|
{
|
|
'key': '{key.test}',
|
|
'custom_text': 'Some Value'
|
|
}
|
|
]
|
|
}
|
|
|
|
# Should handle gracefully and use empty string
|
|
replacements = self.generator._build_replacement_dict(mappings, data)
|
|
self.assertEqual(replacements, {'{key.test}': ''})
|
|
|
|
def test_error_handling_for_non_string_values(self):
|
|
"""Test that non-string values are converted properly"""
|
|
mappings = {
|
|
'placeholders': [
|
|
{'key': '{key.score}', 'value_type': 'user_field', 'value_field': 'score'},
|
|
{'key': '{key.passed}', 'value_type': 'user_field', 'value_field': 'passed'},
|
|
{'key': '{key.date}', 'value_type': 'user_field', 'value_field': 'completion_date'}
|
|
]
|
|
}
|
|
|
|
# Provide non-string values
|
|
data = {
|
|
'score': 95,
|
|
'passed': True,
|
|
'completion_date': None
|
|
}
|
|
|
|
replacements = self.generator._build_replacement_dict(mappings, data)
|
|
|
|
# All values should be converted to strings
|
|
self.assertEqual(replacements['{key.score}'], '95')
|
|
self.assertEqual(replacements['{key.passed}'], 'True')
|
|
self.assertEqual(replacements['{key.date}'], '') # None becomes empty string
|
|
|
|
|
|
@given(test_data=docx_with_mappings_and_data())
|
|
@settings(
|
|
max_examples=100,
|
|
deadline=None,
|
|
suppress_health_check=[HealthCheck.function_scoped_fixture]
|
|
)
|
|
def test_property_13_placeholder_replacement_with_actual_data(self, test_data):
|
|
"""
|
|
Feature: survey-custom-certificate-template, Property 13: Placeholder replacement with actual data
|
|
|
|
For any certificate being generated, all placeholders should be replaced
|
|
with actual participant and survey data according to the configured mappings.
|
|
|
|
Validates: Requirements 5.2
|
|
|
|
This property test verifies that:
|
|
1. All placeholders in the template are replaced with actual data
|
|
2. No placeholders remain in the generated document
|
|
3. The replacement values match the configured mappings
|
|
4. Custom text is used when specified
|
|
5. Dynamic data from the data dictionary is used when specified
|
|
"""
|
|
docx_binary, mappings, data = test_data
|
|
|
|
# Load the original template to get the placeholders
|
|
original_doc = Document(BytesIO(docx_binary))
|
|
original_text = self._extract_all_text(original_doc)
|
|
|
|
# Extract placeholders from original document
|
|
placeholder_pattern = r'\{key\.[a-zA-Z0-9_]+\}'
|
|
original_placeholders = set(re.findall(placeholder_pattern, original_text))
|
|
|
|
# Skip if no placeholders (edge case)
|
|
if not original_placeholders:
|
|
return
|
|
|
|
# Replace placeholders using the generator
|
|
result_doc = self.generator.replace_placeholders(
|
|
Document(BytesIO(docx_binary)),
|
|
mappings,
|
|
data
|
|
)
|
|
|
|
# Extract text from result document
|
|
result_text = self._extract_all_text(result_doc)
|
|
|
|
# Property 1: No placeholders should remain in the result
|
|
remaining_placeholders = set(re.findall(placeholder_pattern, result_text))
|
|
self.assertEqual(
|
|
len(remaining_placeholders),
|
|
0,
|
|
msg=f"Placeholders still present in result: {remaining_placeholders}"
|
|
)
|
|
|
|
# Property 2: All mapped placeholders should have their values in the result
|
|
for mapping in mappings.get('placeholders', []):
|
|
placeholder_key = mapping.get('key', '')
|
|
|
|
# Skip if this placeholder wasn't in the original document
|
|
if placeholder_key not in original_placeholders:
|
|
continue
|
|
|
|
# Determine expected replacement value
|
|
if mapping.get('value_type') == 'custom_text':
|
|
expected_value = mapping.get('custom_text', '')
|
|
else:
|
|
value_field = mapping.get('value_field', '')
|
|
expected_value = data.get(value_field, '')
|
|
|
|
# Convert to string for comparison
|
|
expected_value = str(expected_value) if expected_value else ''
|
|
|
|
# If expected value is not empty, it should appear in the result
|
|
# (Empty values are valid - they replace placeholders with empty strings)
|
|
if expected_value:
|
|
self.assertIn(
|
|
expected_value,
|
|
result_text,
|
|
msg=f"Expected value '{expected_value}' for placeholder '{placeholder_key}' "
|
|
f"not found in result document"
|
|
)
|
|
|
|
# Property 3: The original placeholder should not appear in the result
|
|
for placeholder in original_placeholders:
|
|
self.assertNotIn(
|
|
placeholder,
|
|
result_text,
|
|
msg=f"Original placeholder '{placeholder}' still present in result"
|
|
)
|
|
|
|
def _extract_all_text(self, document):
|
|
"""
|
|
Extract all text from a document including paragraphs, tables, headers, and footers.
|
|
|
|
Args:
|
|
document: python-docx Document object
|
|
|
|
Returns:
|
|
str: All text content concatenated
|
|
"""
|
|
text_parts = []
|
|
|
|
# Extract from paragraphs
|
|
for paragraph in document.paragraphs:
|
|
text_parts.append(paragraph.text)
|
|
|
|
# Extract from tables
|
|
for table in document.tables:
|
|
for row in table.rows:
|
|
for cell in row.cells:
|
|
for paragraph in cell.paragraphs:
|
|
text_parts.append(paragraph.text)
|
|
|
|
# Extract from headers and footers
|
|
for section in document.sections:
|
|
# Header
|
|
for paragraph in section.header.paragraphs:
|
|
text_parts.append(paragraph.text)
|
|
|
|
# Footer
|
|
for paragraph in section.footer.paragraphs:
|
|
text_parts.append(paragraph.text)
|
|
|
|
return ' '.join(text_parts)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|