#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Standalone unit tests for CertificateGenerator (no Odoo dependency). This script runs the certificate generator unit tests without requiring the full Odoo environment, making it easier to verify functionality during development. Task 5.5: Write unit tests for certificate generator - Test placeholder replacement logic - Test data retrieval from models - Test error handling for missing LibreOffice - Requirements: 5.2, 5.3, 6.2, 6.4 """ import sys import unittest import os import tempfile from io import BytesIO try: from docx import Document DOCX_AVAILABLE = True except ImportError: print("ERROR: python-docx is not installed. Install with: pip install python-docx") sys.exit(1) # Add parent directory to path to import the generator sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) from services.certificate_generator import CertificateGenerator class TestCertificateGeneratorStandalone(unittest.TestCase): """Standalone 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() # ======================================================================== # PLACEHOLDER REPLACEMENT LOGIC TESTS (Requirement 5.2) # ======================================================================== 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 (Requirement 6.4)""" 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_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") # ======================================================================== # DATA RETRIEVAL TESTS (Requirement 5.3) # ======================================================================== 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_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 def test_error_handling_for_malformed_mappings(self): """Test that malformed mappings are handled gracefully (Requirement 6.4)""" # 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}': ''}) # ======================================================================== # LIBREOFFICE ERROR HANDLING TESTS (Requirement 6.2) # ======================================================================== def test_libreoffice_availability_check(self): """Test LibreOffice availability checking (Requirement 6.2)""" # 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) print(f"\nLibreOffice not available: {error_message}") else: # If available, error message should be empty self.assertEqual(error_message, '') print("\nLibreOffice is available") # 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 (Requirement 6.2)""" # 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()) print(f"\nCorrectly raised error: {context.exception}") # 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_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)) # ======================================================================== # INPUT VALIDATION TESTS # ======================================================================== 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 = {'some_field': 'some_value'} # Provide data to pass validation 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_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) print("\nEnd-to-end test passed with PDF conversion") 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 print(f"\nEnd-to-end test skipped PDF conversion (LibreOffice not available): {e}") pass else: # Re-raise if it's a different error raise def run_tests(): """Run all tests and display results""" print("=" * 70) print("CERTIFICATE GENERATOR UNIT TESTS (Task 5.5)") print("=" * 70) print("\nTesting:") print(" - Placeholder replacement logic (Requirement 5.2)") print(" - Data retrieval from models (Requirement 5.3)") print(" - Error handling for missing LibreOffice (Requirement 6.2)") print(" - Error handling for missing data (Requirement 6.4)") print("=" * 70) print() # Create test suite loader = unittest.TestLoader() suite = loader.loadTestsFromTestCase(TestCertificateGeneratorStandalone) # Run tests with verbose output runner = unittest.TextTestRunner(verbosity=2) result = runner.run(suite) # Print summary print("\n" + "=" * 70) print("TEST SUMMARY") print("=" * 70) print(f"Tests run: {result.testsRun}") print(f"Successes: {result.testsRun - len(result.failures) - len(result.errors)}") print(f"Failures: {len(result.failures)}") print(f"Errors: {len(result.errors)}") print("=" * 70) return result.wasSuccessful() if __name__ == '__main__': success = run_tests() sys.exit(0 if success else 1)