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