survey_custom_certificate_t.../tests/test_certificate_generator.py
2025-11-29 08:46:04 +07:00

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()