diff --git a/certified_builder/certificates_on_solana.py b/certified_builder/certificates_on_solana.py index 92c4031..ca96178 100644 --- a/certified_builder/certificates_on_solana.py +++ b/certified_builder/certificates_on_solana.py @@ -21,7 +21,7 @@ class CertificatesOnSolana: @staticmethod def register_certificate_on_solana(certificate_data: dict) -> dict: - logger.info("Registering certificate on Solana blockchain") + logger.info("Registering certificate on Solana blockchain with data: %s", certificate_data) """ Registers a certificate on the Solana blockchain. @@ -41,11 +41,10 @@ def register_certificate_on_solana(certificate_data: dict) -> dict: }, json=certificate_data ) - logger.info(f"Solana response status code: {response.status_code}") + logger.info(f"Solana response status code: {response.status_code}") response.raise_for_status() solana_response = response.json() return solana_response - logger.info("Certificate registered successfully on Solana") except Exception as e: logger.error(f"Error registering certificate on Solana: {str(e)}") raise CertificatesOnSolanaException(details=str(e), cause=e) \ No newline at end of file diff --git a/certified_builder/certified_builder.py b/certified_builder/certified_builder.py index 1a4d435..b7c5fc0 100644 --- a/certified_builder/certified_builder.py +++ b/certified_builder/certified_builder.py @@ -1,10 +1,9 @@ import logging -import tempfile import os from typing import List from PIL import Image, ImageDraw, ImageFont -from io import BytesIO from models.participant import Participant +from config import config from certified_builder.utils.fetch_file_certificate import fetch_file_certificate from certified_builder.certificates_on_solana import CertificatesOnSolana from certified_builder.make_qrcode import MakeQRCode @@ -33,6 +32,7 @@ def build_certificates(self, participants: List[Participant]): # Cache for background and logo if they are the same for all participants certificate_template = None logo = None + logo_tech_floripa = None # Check if all participants share the same background and logo if participants: @@ -46,6 +46,9 @@ def build_certificates(self, participants: List[Participant]): if all_same_logo: logo = self._download_image(first_participant.certificate.logo) + if not logo_tech_floripa: + logo_tech_floripa = self._download_image(config.TECH_FLORIPA_LOGO_URL) + for participant in participants: try: # Register certificate on Solana, with returned data extract url for verification @@ -57,13 +60,13 @@ def build_certificates(self, participants: List[Participant]): "certificate_code": participant.formated_validation_code() } ) - + # alteração: agora usamos a função renomeada que apenas extrai o explorer_url participant.authenticity_verification_url = extract_solana_explorer_url(solana_response=solana_response) if not participant.authenticity_verification_url: raise RuntimeError("Failed to get authenticity verification URL from Solana response") - + logger.info(f"URL de verificação de autenticidade: {participant.authenticity_verification_url}") # Download template and logo only if they are not shared if not all_same_background: certificate_template = self._download_image(participant.certificate.background) @@ -71,7 +74,7 @@ def build_certificates(self, participants: List[Participant]): logo = self._download_image(participant.certificate.logo) # Generate and save certificate - certificate_generated = self.generate_certificate(participant, certificate_template, logo) + certificate_generated = self.generate_certificate(participant, certificate_template, logo, logo_tech_floripa) certificate_path = self.save_certificate(certificate_generated, participant) results.append({ @@ -121,7 +124,7 @@ def _ensure_valid_rgba(self, img: Image) -> Image: new_img.paste(img.convert('RGB'), (0, 0)) return new_img - def generate_certificate(self, participant: Participant, certificate_template: Image, logo: Image): + def generate_certificate(self, participant: Participant, certificate_template: Image, logo: Image, logo_tech_floripa: Image): """Generate a certificate for a participant.""" try: # Ensure images have valid transparency channels @@ -145,9 +148,10 @@ def generate_certificate(self, participant: Participant, certificate_template: I # Fallback without using the logo as its own mask overlay.paste(logo, (50, 50)) - + url_qr_code = f"{config.TECH_FLORIPA_CERTIFICATE_VALIDATE_URL}?validate_code={participant.formated_validation_code()}" qrcode_size = (150, 150) - qr_code_image_io = MakeQRCode.generate_qr_code(participant.authenticity_verification_url) + logger.info(f"URL do QR code: {url_qr_code} para o certificado de {participant.name_completed()}") + qr_code_image_io = MakeQRCode.generate_qr_code(url_qr_code, logo_tech_floripa=logo_tech_floripa) qr_code_image = Image.open(qr_code_image_io).convert("RGBA") # comentário: para manter o QR nítido, usamos NEAREST ao redimensionar if qr_code_image.size != qrcode_size: diff --git a/certified_builder/make_qrcode.py b/certified_builder/make_qrcode.py index 21bcd63..35e2cfd 100644 --- a/certified_builder/make_qrcode.py +++ b/certified_builder/make_qrcode.py @@ -2,18 +2,19 @@ import qrcode import logging from io import BytesIO +from PIL import Image from qrcode.image.pil import PilImage logger = logging.getLogger(__name__) class MakeQRCode: @staticmethod - def generate_qr_code(data: str) -> BytesIO: + def generate_qr_code(data: str, logo_tech_floripa: Image) -> BytesIO: try: - logger.info("Generating QR code ") + logger.info(f"Generating QR code for {data}") qr = qrcode.QRCode( version=1, - error_correction=qrcode.constants.ERROR_CORRECT_L, + error_correction=qrcode.constants.ERROR_CORRECT_H, box_size=10, border=4, ) @@ -22,10 +23,27 @@ def generate_qr_code(data: str) -> BytesIO: qr.make(fit=True) img = qr.make_image(fill_color="black", back_color="transparent", image_factory=PilImage) img = img.convert("RGBA") + + # Redimensiona o logo para aproximadamente 30% do tamanho do QR code + # Tamanho limitado para não interferir nos padrões de detecção nos cantos + qr_width, qr_height = img.size + logo_size = int(min(qr_width, qr_height) * 0.3) + logo_resized = logo_tech_floripa.copy() + logo_resized.thumbnail((logo_size, logo_size), Image.Resampling.LANCZOS) + logo_resized = logo_resized.convert("RGBA") + + # Calcula a posição central para colar o logo + logo_width, logo_height = logo_resized.size + position = ((qr_width - logo_width) // 2, (qr_height - logo_height) // 2) + + # Cola o logo no centro do QR code mantendo transparência + # Com ERROR_CORRECT_H (30% redundância), o QR code permanece legível mesmo com o logo + img.paste(logo_resized, position, logo_resized) + byte_io = BytesIO() img.save(byte_io, format='PNG') byte_io.seek(0) - logger.info("QR code generated successfully") + logger.info(f"QR code generated successfully for {data}") return byte_io except Exception as e: logging.error(f"Failed to generate QR code: {e}") diff --git a/config.py b/config.py index 39bfb47..1d46f3b 100644 --- a/config.py +++ b/config.py @@ -10,7 +10,7 @@ class Config(BaseSettings): SERVICE_URL_REGISTRATION_API_SOLANA: str SERVICE_API_KEY_REGISTRATION_API_SOLANA: str TECH_FLORIPA_CERTIFICATE_VALIDATE_URL: str - + TECH_FLORIPA_LOGO_URL: str class Config: env_file = ".env" env_file_encoding = "utf-8" diff --git a/readme.md b/readme.md index b7d2542..e786a70 100644 --- a/readme.md +++ b/readme.md @@ -1,6 +1,6 @@ # Certified Builder Py -Sistema de geração automática de certificados para eventos usando AWS Lambda e Docker. O projeto gera certificados personalizados para participantes de eventos, processando mensagens do SQS e utilizando templates predefinidos. +Sistema de geração automática de certificados para eventos usando AWS Lambda e Docker. O projeto gera certificados personalizados para participantes de eventos, processando mensagens do SQS e utilizando templates predefinidos. Os certificados incluem QR code para validação com logo Tech Floripa no centro e são registrados na blockchain Solana para autenticação. [![Continuos Integration -Testing - Certified Builder Py](https://github.com/maxsonferovante/certified_builder_py/actions/workflows/workflow_testing.yaml/badge.svg)](https://github.com/maxsonferovante/certified_builder_py/actions/workflows/workflow_testing.yaml) @@ -13,11 +13,13 @@ Sistema de geração automática de certificados para eventos usando AWS Lambda - Código de validação único - Logo do evento - Detalhes do evento em três linhas centralizadas - - QR Code para validação + - QR Code para validação com logo Tech Floripa no centro + - Registro na blockchain Solana para autenticação - Processamento de mensagens SQS - Execução em container Docker - Deploy automatizado para AWS Lambda - Integração com AWS ECR +- Envio de mensagens para fila de notificação com dados do certificado ## Estrutura do Projeto @@ -25,16 +27,25 @@ Sistema de geração automática de certificados para eventos usando AWS Lambda project_root/ ├── certified_builder/ │ ├── certified_builder.py # Classe principal de geração de certificados +│ ├── make_qrcode.py # Geração de QR codes com logo +│ ├── certificates_on_solana.py # Integração com blockchain Solana +│ ├── solana_explorer_url.py # Extração de URL do Solana Explorer │ └── utils/ │ └── fetch_file_certificate.py # Utilitário para download de imagens ├── models/ │ ├── participant.py # Modelo de dados do participante │ ├── certificate.py # Modelo de dados do certificado │ └── event.py # Modelo de dados do evento +├── aws/ +│ ├── sqs_service.py # Serviço para envio de mensagens SQS +│ ├── s3_service.py # Serviço para upload no S3 +│ └── boto_aws.py # Configuração do cliente AWS ├── fonts/ │ ├── PinyonScript/ # Fonte para o nome do participante │ └── ChakraPetch/ # Fonte para detalhes e código de validação +├── tests/ # Testes automatizados ├── lambda_function.py # Handler da função Lambda +├── config.py # Configurações do projeto ├── Dockerfile # Configuração do container └── requirements.txt # Dependências do projeto ``` @@ -45,12 +56,16 @@ project_root/ - Pillow (Processamento de imagens) - httpx (Requisições HTTP) - Pydantic (Validação de dados) +- qrcode (Geração de QR codes) - Docker - AWS Lambda - AWS ECR - AWS SQS +- Solana Blockchain (Registro de certificados) -## Formato da Mensagem SQS +## Formato da Mensagem SQS (Entrada) + +A Lambda recebe mensagens do SQS com os dados dos participantes para gerar os certificados: ```json { @@ -76,6 +91,38 @@ project_root/ } ``` +## Formato da Mensagem SQS (Saída - Fila de Notificação) + +Após a geração dos certificados, uma mensagem é enviada para outra fila SQS com os dados do certificado gerado: + +```json +[ + { + "order_id": 123, + "validation_code": "ABC-DEF-GHI", + "authenticity_verification_url": "https://explorer.solana.com/tx/...?cluster=devnet", + "product_id": 456, + "product_name": "Nome do Evento", + "email": "email@exemplo.com", + "certificate_key": "certificates/456/123/Nome_Sobrenome_Nome_do_Evento_ABC-DEF-GHI.png", + "success": true + } +] +``` + +### Campos da Mensagem de Saída + +- **`order_id`**: ID do pedido/ordem +- **`validation_code`**: Código de validação do certificado (formato: XXX-XXX-XXX) +- **`authenticity_verification_url`**: URL do Solana Explorer para verificação na blockchain +- **`product_id`**: ID do produto/evento +- **`product_name`**: Nome do produto/evento +- **`email`**: Email do participante +- **`certificate_key`**: Chave do certificado no S3 (formato: `certificates/{product_id}/{order_id}/{nome_certificado}.png`) +- **`success`**: Indica se a geração foi bem-sucedida (true/false) + +**Nota**: A mensagem é enviada como um array, podendo conter múltiplos certificados quando processados em lote. + ## Desenvolvimento Local ### Pré-requisitos @@ -118,11 +165,14 @@ O deploy é automatizado através do GitHub Actions: ## Estrutura do Certificado Gerado -- **Logo**: Canto superior esquerdo (150x150 pixels) +- **Logo do Evento**: Canto superior esquerdo (150x150 pixels máximo, redimensionado automaticamente) - **Nome**: Centro do certificado (fonte Pinyon Script) - **Detalhes**: Três linhas centralizadas abaixo do nome (fonte Chakra Petch) +- **QR Code**: Posicionado abaixo do logo (150x150 pixels) com logo Tech Floripa centralizado + - Contém URL de validação com código único: `https://tech.floripa.br/certificate-validate/?validate_code=XXX-XXX-XXX` + - Usa correção de erros nível H (30% redundância) para garantir leitura mesmo com logo +- **Texto "Scan to Validate"**: Abaixo do QR code, centralizado - **Código de Validação**: Canto inferior direito (fonte Chakra Petch) -- **QR Code**: Canto inferior direito para validação online ## Contribuindo diff --git a/tests/conftest.py b/tests/conftest.py index 967d1ec..93e9b91 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -22,7 +22,8 @@ class MockConfig: SERVICE_API_KEY_REGISTRATION_API_SOLANA = "test-api-key" # comentário: URL de validação mockada para o Tech Floripa usada nos testes TECH_FLORIPA_CERTIFICATE_VALIDATE_URL = "https://example.test/certificate-validate/" - + TECH_FLORIPA_LOGO_URL = "https://example.test/logo.png" + # comentário: expõe tanto a classe quanto a instância, como o módulo real faria mock_module.Config = MockConfig mock_module.config = MockConfig() diff --git a/tests/test_certified_builder.py b/tests/test_certified_builder.py index ffd3dd8..de7fbb2 100644 --- a/tests/test_certified_builder.py +++ b/tests/test_certified_builder.py @@ -49,13 +49,17 @@ def mock_certificate_template(): def mock_logo(): return Image.new("RGBA", (150, 150), (255, 255, 255, 0)) +@pytest.fixture +def mock_logo_tech_floripa(): + return Image.new("RGBA", (100, 100), (255, 255, 255, 255)) + @pytest.fixture def certified_builder(): return CertifiedBuilder() -def test_generate_certificate(certified_builder, mock_participant, mock_certificate_template, mock_logo): +def test_generate_certificate(certified_builder, mock_participant, mock_certificate_template, mock_logo, mock_logo_tech_floripa): with patch('certified_builder.utils.fetch_file_certificate.fetch_file_certificate', side_effect=[mock_certificate_template, mock_logo]): - certificate = certified_builder.generate_certificate(mock_participant, mock_certificate_template, mock_logo) + certificate = certified_builder.generate_certificate(mock_participant, mock_certificate_template, mock_logo, mock_logo_tech_floripa) assert isinstance(certificate, Image.Image) assert certificate.size == mock_certificate_template.size assert certificate.mode == "RGBA" @@ -78,11 +82,21 @@ def test_create_validation_code_image(certified_builder, mock_participant, mock_ assert validation_code_image.size == mock_certificate_template.size assert validation_code_image.mode == "RGBA" -def test_build_certificates(certified_builder, mock_participant, mock_certificate_template, mock_logo): +def test_build_certificates(certified_builder, mock_participant, mock_certificate_template, mock_logo, mock_logo_tech_floripa): participants = [mock_participant] # comentário: mock do download de imagens e da resposta do serviço Solana para evitar chamada externa - with patch('certified_builder.utils.fetch_file_certificate.fetch_file_certificate', side_effect=[mock_certificate_template, mock_logo]), \ + # Função que retorna o mock baseado na URL chamada + def mock_download_image(url): + if url == mock_participant.certificate.background: + return mock_certificate_template + elif url == mock_participant.certificate.logo: + return mock_logo + elif url == "https://example.test/logo.png": # TECH_FLORIPA_LOGO_URL do config mock + return mock_logo_tech_floripa + raise ValueError(f"URL não mockada: {url}") + + with patch.object(certified_builder, '_download_image', side_effect=mock_download_image), \ patch('certified_builder.certified_builder.CertificatesOnSolana.register_certificate_on_solana', return_value={ # comentário: mock alinhado ao contrato atual do serviço "status": "sucesso", @@ -128,12 +142,12 @@ def _count_non_transparent_pixels(img: Image.Image) -> int: return sum(1 for p in alpha.getdata() if p != 0) -def test_scan_to_validate_is_centered_and_below_qr(certified_builder, mock_participant, mock_certificate_template, mock_logo): +def test_scan_to_validate_is_centered_and_below_qr(certified_builder, mock_participant, mock_certificate_template, mock_logo, mock_logo_tech_floripa): # Garante url para QR mock_participant.authenticity_verification_url = "https://example.com/verify" with patch('certified_builder.utils.fetch_file_certificate.fetch_file_certificate', side_effect=[mock_certificate_template, mock_logo]): - result = certified_builder.generate_certificate(mock_participant, mock_certificate_template, mock_logo) + result = certified_builder.generate_certificate(mock_participant, mock_certificate_template, mock_logo, mock_logo_tech_floripa) # Recalcula posição esperada do texto seguindo a mesma lógica do código qrcode_size = (150, 150) @@ -163,11 +177,11 @@ def test_scan_to_validate_is_centered_and_below_qr(certified_builder, mock_parti assert _count_non_transparent_pixels(cropped) > 0, "Texto 'Scan to Validate' não encontrado na área esperada" -def test_qr_is_placed_at_expected_region(certified_builder, mock_participant, mock_certificate_template, mock_logo): +def test_qr_is_placed_at_expected_region(certified_builder, mock_participant, mock_certificate_template, mock_logo, mock_logo_tech_floripa): mock_participant.authenticity_verification_url = "https://example.com/verify" with patch('certified_builder.utils.fetch_file_certificate.fetch_file_certificate', side_effect=[mock_certificate_template, mock_logo]): - result = certified_builder.generate_certificate(mock_participant, mock_certificate_template, mock_logo) + result = certified_builder.generate_certificate(mock_participant, mock_certificate_template, mock_logo, mock_logo_tech_floripa) # QR é esperado em (50,200) com 150x150 qr_left, qr_top = 50, 200 @@ -180,3 +194,248 @@ def test_qr_is_placed_at_expected_region(certified_builder, mock_participant, mo below_region = result.crop((qr_left, qr_bottom, qr_right, min(qr_bottom + 30, result.height))) assert _count_non_transparent_pixels(below_region) > 0, "Nenhum conteúdo encontrado abaixo do QR onde o texto deveria estar" + +def test_name_is_centered_on_certificate(certified_builder, mock_participant, mock_certificate_template, mock_logo, mock_logo_tech_floripa): + """Verifica que o nome do participante está centralizado no certificado.""" + mock_participant.authenticity_verification_url = "https://example.com/verify" + + with patch('certified_builder.utils.fetch_file_certificate.fetch_file_certificate', side_effect=[mock_certificate_template, mock_logo]): + result = certified_builder.generate_certificate(mock_participant, mock_certificate_template, mock_logo, mock_logo_tech_floripa) + + # Verifica que há conteúdo na região central onde o nome deveria estar + center_x = result.width // 2 + center_y = result.height // 2 + name_region = result.crop((center_x - 200, center_y - 50, center_x + 200, center_y + 50)) + + assert _count_non_transparent_pixels(name_region) > 0, "Nome não encontrado na região central do certificado" + + +def test_validation_code_is_at_bottom_right(certified_builder, mock_participant, mock_certificate_template, mock_logo, mock_logo_tech_floripa): + """Verifica que o código de validação está posicionado no canto inferior direito.""" + mock_participant.authenticity_verification_url = "https://example.com/verify" + + with patch('certified_builder.utils.fetch_file_certificate.fetch_file_certificate', side_effect=[mock_certificate_template, mock_logo]): + result = certified_builder.generate_certificate(mock_participant, mock_certificate_template, mock_logo, mock_logo_tech_floripa) + + # Verifica região inferior direita onde o código de validação deveria estar + validation_region = result.crop((result.width - 250, result.height - 80, result.width, result.height)) + + assert _count_non_transparent_pixels(validation_region) > 0, "Código de validação não encontrado no canto inferior direito" + + +def test_logo_is_positioned_at_top_left(certified_builder, mock_participant, mock_certificate_template, mock_logo_tech_floripa): + """Verifica que o logo está posicionado no canto superior esquerdo.""" + mock_participant.authenticity_verification_url = "https://example.com/verify" + # Usa um logo visível (não transparente) para o teste + visible_logo = Image.new("RGBA", (150, 150), (255, 255, 255, 255)) + + with patch('certified_builder.utils.fetch_file_certificate.fetch_file_certificate', side_effect=[mock_certificate_template, visible_logo]): + result = certified_builder.generate_certificate(mock_participant, mock_certificate_template, visible_logo, mock_logo_tech_floripa) + + # Logo é esperado em (50, 50) com tamanho máximo de 150x150 + logo_region = result.crop((50, 50, 200, 200)) + + assert _count_non_transparent_pixels(logo_region) > 0, "Logo não encontrado na região esperada" + + +def test_large_logo_is_resized(certified_builder, mock_participant, mock_certificate_template, mock_logo_tech_floripa): + """Verifica que logos grandes são redimensionados para o tamanho máximo.""" + mock_participant.authenticity_verification_url = "https://example.com/verify" + large_logo = Image.new("RGBA", (300, 300), (255, 255, 255, 255)) + + with patch('certified_builder.utils.fetch_file_certificate.fetch_file_certificate', side_effect=[mock_certificate_template, large_logo]): + result = certified_builder.generate_certificate(mock_participant, mock_certificate_template, large_logo, mock_logo_tech_floripa) + + # Verifica que o logo foi redimensionado (não deve ocupar toda a região de 300x300) + logo_region = result.crop((50, 50, 200, 200)) + + assert _count_non_transparent_pixels(logo_region) > 0, "Logo redimensionado não encontrado" + + +def test_details_are_split_into_three_lines(certified_builder, mock_participant, mock_certificate_template, mock_logo, mock_logo_tech_floripa): + """Verifica que os detalhes do certificado são divididos em 3 linhas.""" + mock_participant.authenticity_verification_url = "https://example.com/verify" + + with patch('certified_builder.utils.fetch_file_certificate.fetch_file_certificate', side_effect=[mock_certificate_template, mock_logo]): + result = certified_builder.generate_certificate(mock_participant, mock_certificate_template, mock_logo, mock_logo_tech_floripa) + + # Verifica região onde os detalhes deveriam estar (abaixo do centro) + center_y = result.height // 2 + details_region = result.crop((0, center_y + 50, result.width, center_y + 150)) + + assert _count_non_transparent_pixels(details_region) > 0, "Detalhes não encontrados na região esperada" + + +def test_build_certificates_with_multiple_participants_same_resources(certified_builder, mock_participant, mock_certificate_template, mock_logo, mock_logo_tech_floripa): + """Testa geração de certificados para múltiplos participantes com mesmo background e logo.""" + second_participant = Participant( + first_name="Maria", + last_name="Silva", + email="maria@example.com", + phone="(48) 99999-9999", + cpf="111.111.111-11", + certificate=mock_participant.certificate, + event=mock_participant.event + ) + + participants = [mock_participant, second_participant] + + def mock_download_image(url): + if url == mock_participant.certificate.background: + return mock_certificate_template + elif url == mock_participant.certificate.logo: + return mock_logo + elif url == "https://example.test/logo.png": + return mock_logo_tech_floripa + raise ValueError(f"URL não mockada: {url}") + + with patch.object(certified_builder, '_download_image', side_effect=mock_download_image), \ + patch('certified_builder.certified_builder.CertificatesOnSolana.register_certificate_on_solana', return_value={ + "blockchain": { + "explorer_url": "https://explorer.solana.com/tx/test123?cluster=devnet" + } + }), \ + patch.object(certified_builder, 'save_certificate') as mock_save: + + results = certified_builder.build_certificates(participants) + + assert len(results) == 2 + assert all(r["success"] for r in results) + assert mock_save.call_count == 2 + + +def test_build_certificates_with_different_backgrounds(certified_builder, mock_participant, mock_logo, mock_logo_tech_floripa): + """Testa geração de certificados para participantes com backgrounds diferentes.""" + different_background = "https://example.com/different-background.png" + different_template = Image.new("RGBA", (1920, 1080), (200, 200, 200, 255)) + + second_certificate = Certificate( + details=mock_participant.certificate.details, + logo=mock_participant.certificate.logo, + background=different_background + ) + + second_participant = Participant( + first_name="João", + last_name="Santos", + email="joao@example.com", + phone="(48) 88888-8888", + cpf="222.222.222-22", + certificate=second_certificate, + event=mock_participant.event + ) + + participants = [mock_participant, second_participant] + + def mock_download_image(url): + if url == mock_participant.certificate.background: + return Image.new("RGBA", (1920, 1080), (255, 255, 255, 0)) + elif url == different_background: + return different_template + elif url == mock_participant.certificate.logo: + return mock_logo + elif url == "https://example.test/logo.png": + return mock_logo_tech_floripa + raise ValueError(f"URL não mockada: {url}") + + with patch.object(certified_builder, '_download_image', side_effect=mock_download_image), \ + patch('certified_builder.certified_builder.CertificatesOnSolana.register_certificate_on_solana', return_value={ + "blockchain": { + "explorer_url": "https://explorer.solana.com/tx/test123?cluster=devnet" + } + }), \ + patch.object(certified_builder, 'save_certificate') as mock_save: + + results = certified_builder.build_certificates(participants) + + assert len(results) == 2 + assert all(r["success"] for r in results) + + +def test_build_certificates_handles_solana_registration_error(certified_builder, mock_participant, mock_certificate_template, mock_logo, mock_logo_tech_floripa): + """Testa tratamento de erro quando o registro na Solana falha.""" + participants = [mock_participant] + + def mock_download_image(url): + if url == mock_participant.certificate.background: + return mock_certificate_template + elif url == mock_participant.certificate.logo: + return mock_logo + elif url == "https://example.test/logo.png": + return mock_logo_tech_floripa + raise ValueError(f"URL não mockada: {url}") + + with patch.object(certified_builder, '_download_image', side_effect=mock_download_image), \ + patch('certified_builder.certified_builder.CertificatesOnSolana.register_certificate_on_solana', return_value={ + "blockchain": {} # Sem explorer_url - simula erro + }): + + results = certified_builder.build_certificates(participants) + + assert len(results) == 1 + assert results[0]["success"] == False + assert "Failed to get authenticity verification URL" in results[0]["error"] + + +def test_save_certificate_saves_to_temp_directory(certified_builder, mock_participant, mock_certificate_template, mock_logo, mock_logo_tech_floripa): + """Testa que o certificado é salvo no diretório temporário.""" + mock_participant.authenticity_verification_url = "https://example.com/verify" + + with patch('certified_builder.utils.fetch_file_certificate.fetch_file_certificate', side_effect=[mock_certificate_template, mock_logo]): + certificate = certified_builder.generate_certificate(mock_participant, mock_certificate_template, mock_logo, mock_logo_tech_floripa) + file_path = certified_builder.save_certificate(certificate, mock_participant) + + assert file_path.startswith("/tmp/certificates/") + assert file_path.endswith(".png") + + import os + assert os.path.exists(file_path), "Arquivo do certificado não foi criado" + + +def test_generate_certificate_with_long_name(certified_builder, mock_certificate, mock_certificate_template, mock_logo, mock_logo_tech_floripa): + """Testa geração de certificado com nome muito longo.""" + mock_event = Event( + order_id=1, + product_id=1, + product_name="Evento Teste", + date=datetime.now(), + time_checkin=datetime.now(), + checkin_latitude=0.0, + checkin_longitude=0.0 + ) + + long_name_participant = Participant( + first_name="João Pedro", + last_name="da Silva Santos Oliveira", + email="joao@example.com", + phone="(48) 99999-9999", + cpf="123.456.789-00", + certificate=mock_certificate, + event=mock_event + ) + long_name_participant.authenticity_verification_url = "https://example.com/verify" + + with patch('certified_builder.utils.fetch_file_certificate.fetch_file_certificate', side_effect=[mock_certificate_template, mock_logo]): + result = certified_builder.generate_certificate(long_name_participant, mock_certificate_template, mock_logo, mock_logo_tech_floripa) + + assert isinstance(result, Image.Image) + assert result.size == mock_certificate_template.size + + +def test_ensure_valid_rgba_converts_rgb_image(certified_builder): + """Testa que _ensure_valid_rgba converte imagens RGB para RGBA.""" + rgb_image = Image.new("RGB", (100, 100), (255, 0, 0)) + rgba_image = certified_builder._ensure_valid_rgba(rgb_image) + + assert rgba_image.mode == "RGBA" + assert rgba_image.size == rgb_image.size + + +def test_ensure_valid_rgba_preserves_rgba_image(certified_builder): + """Testa que _ensure_valid_rgba preserva imagens já em RGBA.""" + rgba_image = Image.new("RGBA", (100, 100), (255, 0, 0, 128)) + result = certified_builder._ensure_valid_rgba(rgba_image) + + assert result.mode == "RGBA" + assert result.size == rgba_image.size + diff --git a/tests/test_make_qrcode.py b/tests/test_make_qrcode.py index ee45574..2ea7efa 100644 --- a/tests/test_make_qrcode.py +++ b/tests/test_make_qrcode.py @@ -4,6 +4,11 @@ from certified_builder.make_qrcode import MakeQRCode +@pytest.fixture +def mock_logo_tech_floripa(): + return Image.new("RGBA", (100, 100), (255, 255, 255, 255)) + + def _count_alpha(img: Image.Image): a = img.split()[-1] zeros = sum(1 for p in a.getdata() if p == 0) @@ -11,9 +16,9 @@ def _count_alpha(img: Image.Image): return zeros, nonzeros -def test_generate_qr_code_returns_image_png_rgba(): +def test_generate_qr_code_returns_image_png_rgba(mock_logo_tech_floripa): data = "https://example.com/verify/ABC123" - byte_io = MakeQRCode.generate_qr_code(data) + byte_io = MakeQRCode.generate_qr_code(data, mock_logo_tech_floripa) assert isinstance(byte_io, BytesIO) @@ -22,9 +27,9 @@ def test_generate_qr_code_returns_image_png_rgba(): assert img.size[0] > 0 and img.size[1] > 0 -def test_qr_has_transparent_background_and_visible_foreground(): +def test_qr_has_transparent_background_and_visible_foreground(mock_logo_tech_floripa): data = "hello world" - byte_io = MakeQRCode.generate_qr_code(data) + byte_io = MakeQRCode.generate_qr_code(data, mock_logo_tech_floripa) img = Image.open(byte_io).convert("RGBA") zeros, nonzeros = _count_alpha(img)