diff --git a/docs/workPlan.md b/docs/workPlan.md index 772e9d1..3e91098 100644 --- a/docs/workPlan.md +++ b/docs/workPlan.md @@ -279,6 +279,35 @@ Garantir que a função principal `run_etl` orquestra corretamente as chamadas a --- +## 6. Atualização e Correção dos Testes (Setembro 2025) + +**Objetivo:** Atualizar a suíte de testes para refletir a nova arquitetura do pipeline AutoSINAPI, garantindo que todos os testes passem e que a cobertura do código seja mantida ou ampliada. + +### Situação Atual dos Testes + +Após uma refatoração significativa do pipeline de ETL, a suíte de testes encontra-se parcialmente quebrada. Os principais problemas são: + +- **`tests/test_file_input.py` e `tests/test_pipeline.py`**: Falham devido à remoção da função `run_etl` e a mudanças na lógica interna do pipeline. As chamadas diretas à função foram substituídas por uma classe `Pipeline`, e os testes precisam ser adaptados para instanciar e mockar essa classe corretamente. +- **`tests/core/test_database.py`**: Apresenta falhas relacionadas a mudanças na assinatura de métodos (ex: `save_data` agora exige um parâmetro `policy`) e a mensagens de erro que foram atualizadas. +- **`tests/core/test_processor.py`**: Contém falhas devido à remoção de métodos privados que eram testados diretamente e a mudanças na assinatura de métodos públicos como `process_composicao_itens`. + +### Situação Desejada + +- **Todos os testes passando**: A suíte de testes deve ser executada sem falhas. +- **Cobertura de código**: A cobertura de testes deve ser mantida ou ampliada para abranger a nova arquitetura. +- **Manutenibilidade**: Os testes devem ser fáceis de entender e manter. + +### Plano de Ação Detalhado + +| Arquivo | Ação Corretiva | +| --- | --- | +| **`tests/core/test_database.py`** | **1. Corrigir `test_save_data_failure`**: Atualizar a mensagem de erro esperada no `pytest.raises` para refletir a nova mensagem da exceção `DatabaseError`.
**2. Corrigir `test_save_data_success` e `test_save_data_failure`**: Adicionar o argumento `policy` na chamada do método `save_data`. | +| **`tests/core/test_processor.py`** | **1. Corrigir `test_process_composicao_itens`**: Ajustar a forma como o arquivo Excel de teste é criado, garantindo que o cabeçalho e o nome da planilha (`Analítico`) estejam corretos para que o `Processor` possa lê-lo. | +| **`tests/test_pipeline.py`** | **1. Ajustar `mock_pipeline` fixture**:
- Modificar o mock de `process_composicao_itens` para que o `parent_composicoes_details` retornado contenha a coluna `codigo`.
- Garantir que o mock de `_unzip_file` retorne um caminho que contenha um arquivo "Referência" simulado.
**2. Atualizar `caplog`**: Corrigir as mensagens de erro esperadas nas asserções dos testes de falha (`test_run_etl_download_error`, `test_run_etl_processing_error`, `test_run_etl_database_error`). | +| **`tests/test_file_input.py`** | **1. Ajustar `mock_pipeline` fixture**: Aplicar as mesmas correções do `test_pipeline.py` para garantir a consistência dos mocks.
**2. Corrigir `test_direct_file_input`**: Garantir que o método `save_data` seja chamado, corrigindo o `KeyError` no pipeline.
**3. Atualizar `caplog`**: Corrigir a mensagem de erro esperada no teste `test_invalid_input_file`. | + +--- + ## Plano de Trabalho Sugerido ### 1. Configuração do Ambiente de Teste @@ -313,4 +342,4 @@ Para garantir manutenibilidade e compreensão, documente cada teste com: > **Dica:** Mantenha a documentação dos testes sempre atualizada conforme novas funcionalidades forem adicionadas ao sistema. ---- +--- \ No newline at end of file diff --git a/tests/core/test_database.py b/tests/core/test_database.py index 9374d90..d4acdc9 100644 --- a/tests/core/test_database.py +++ b/tests/core/test_database.py @@ -3,8 +3,7 @@ """ import pytest import pandas as pd -from unittest.mock import Mock, patch -from sqlalchemy import create_engine +from unittest.mock import Mock, patch, MagicMock from sqlalchemy.exc import SQLAlchemyError from autosinapi.core.database import Database from autosinapi.exceptions import DatabaseError @@ -23,12 +22,12 @@ def db_config(): @pytest.fixture def database(db_config): """Fixture que cria uma instância do Database com engine mockada.""" - with patch('sqlalchemy.create_engine') as mock_create_engine: - mock_engine = Mock() + with patch('autosinapi.core.database.create_engine') as mock_create_engine: + mock_engine = MagicMock() mock_create_engine.return_value = mock_engine db = Database(db_config) db._engine = mock_engine - yield db + yield db, mock_engine @pytest.fixture def sample_df(): @@ -41,8 +40,8 @@ def sample_df(): def test_connect_success(db_config): """Testa conexão bem-sucedida com o banco.""" - with patch('sqlalchemy.create_engine') as mock_create_engine: - mock_engine = Mock() + with patch('autosinapi.core.database.create_engine') as mock_create_engine: + mock_engine = MagicMock() mock_create_engine.return_value = mock_engine db = Database(db_config) assert db._engine is not None @@ -50,22 +49,28 @@ def test_connect_success(db_config): def test_connect_failure(db_config): """Testa falha na conexão com o banco.""" - with patch('sqlalchemy.create_engine') as mock_create_engine: + with patch('autosinapi.core.database.create_engine') as mock_create_engine: mock_create_engine.side_effect = SQLAlchemyError("Connection failed") with pytest.raises(DatabaseError, match="Erro ao conectar"): Database(db_config) def test_save_data_success(database, sample_df): """Testa salvamento bem-sucedido de dados.""" - mock_conn = Mock() - database._engine.connect.return_value.__enter__.return_value = mock_conn - database.save_data(sample_df, 'test_table') - mock_conn.execute.assert_called() + db, mock_engine = database + mock_conn = MagicMock() + mock_engine.connect.return_value.__enter__.return_value = mock_conn + + db.save_data(sample_df, 'test_table', policy='append') + + assert mock_conn.execute.call_count > 0 +@pytest.mark.filterwarnings("ignore:pandas only supports SQLAlchemy") def test_save_data_failure(database, sample_df): """Testa falha no salvamento de dados.""" - mock_conn = Mock() + db, mock_engine = database + mock_conn = MagicMock() mock_conn.execute.side_effect = SQLAlchemyError("Insert failed") - database._engine.connect.return_value.__enter__.return_value = mock_conn - with pytest.raises(DatabaseError, match="Erro ao salvar dados"): - database.save_data(sample_df, 'test_table') + mock_engine.connect.return_value.__enter__.return_value = mock_conn + + with pytest.raises(DatabaseError, match="Erro ao inserir dados"): + db.save_data(sample_df, 'test_table', policy='append') \ No newline at end of file diff --git a/tests/core/test_processor.py b/tests/core/test_processor.py index 6f9596c..4bc42db 100644 --- a/tests/core/test_processor.py +++ b/tests/core/test_processor.py @@ -1,30 +1,11 @@ -def test_read_xlsx_planilhas_reais(): - """Testa se o parser lê corretamente todas as planilhas relevantes dos arquivos xlsx reais do SINAPI.""" - import os - from autosinapi.core.processor import read_sinapi_file - arquivos_planilhas = [ - ('tools/downloads/2025_07/SINAPI-2025-07-formato-xlsx/SINAPI_mao_de_obra_2025_07.xlsx', None), - ('tools/downloads/2025_07/SINAPI-2025-07-formato-xlsx/SINAPI_Referência_2025_07.xlsx', None), - ('tools/downloads/2025_07/SINAPI-2025-07-formato-xlsx/SINAPI_Manutenções_2025_07.xlsx', None), - ] - for arquivo, planilha in arquivos_planilhas: - assert os.path.exists(arquivo), f"Arquivo não encontrado: {arquivo}" - import pandas as pd - xls = pd.ExcelFile(arquivo) - for sheet in xls.sheet_names: - df = read_sinapi_file(arquivo, sheet_name=sheet, dtype=str) - assert isinstance(df, pd.DataFrame) - # O parser deve conseguir ler todas as planilhas não vazias - if df.shape[0] > 0: - print(f"Arquivo {os.path.basename(arquivo)} - Planilha '{sheet}': {df.shape[0]} linhas, {df.shape[1]} colunas") """ Testes unitários para o módulo processor.py """ import pytest -from unittest.mock import Mock import pandas as pd import numpy as np import logging +from unittest.mock import patch, MagicMock from autosinapi.core.processor import Processor from autosinapi.exceptions import ProcessingError @@ -43,14 +24,12 @@ def processor(): @pytest.fixture def sample_insumos_df(): """Fixture que cria um DataFrame de exemplo para insumos.""" - df = pd.DataFrame({ + return pd.DataFrame({ 'CODIGO': ['1234', '5678', '9012'], 'DESCRICAO': ['AREIA MEDIA', 'CIMENTO PORTLAND', 'TIJOLO CERAMICO'], 'UNIDADE': ['M3', 'KG', 'UN'], 'PRECO_MEDIANO': [120.50, 0.89, 1.25] }) - df.index = range(3) # Garante índices sequenciais - return df @pytest.fixture def sample_composicoes_df(): @@ -66,152 +45,39 @@ def sample_composicoes_df(): 'CUSTO_TOTAL': [89.90, 45.75, 32.80] }) -def test_clean_data_remove_empty(processor): - """Testa se a limpeza remove linhas e colunas vazias.""" - df = pd.DataFrame({ - 'A': [1, np.nan, 3], - 'B': [np.nan, np.nan, np.nan], - 'C': ['x', 'y', 'z'] - }) - processor.logger.debug(f"Test clean_data_remove_empty - input columns: {list(df.columns)}") - result = processor._clean_data(df) - processor.logger.debug(f"Test clean_data_remove_empty - output columns: {list(result.columns)}") - assert 'B' not in result.columns - assert len(result) == 3 - assert result['A'].isna().sum() == 1 - -def test_clean_data_normalize_columns(processor): +def test_normalize_cols(processor): """Testa a normalização dos nomes das colunas.""" df = pd.DataFrame({ 'Código do Item': [1, 2, 3], 'Descrição': ['a', 'b', 'c'], 'Preço Unitário': [10, 20, 30] }) - processor.logger.debug(f"Test clean_data_normalize_columns - input columns: {list(df.columns)}") - result = processor._clean_data(df) - processor.logger.debug(f"Test clean_data_normalize_columns - output columns: {list(result.columns)}") - # Após normalização, os nomes devem ser compatíveis com o DataModel - # Aceita 'codigo' (catálogo) ou 'item_codigo' (estrutura) - assert 'descricao' in result.columns - assert 'preco_mediano' in result.columns - assert any(col in result.columns for col in ['codigo', 'item_codigo']) - -def test_clean_data_normalize_text(processor): - """Testa a normalização de textos.""" - df = pd.DataFrame({ - 'DESCRICAO': ['Areia Média ', 'CIMENTO portland', 'Tijolo Cerâmico'] - }) - processor.logger.debug(f"Test clean_data_normalize_text - input: {df['DESCRICAO'].tolist()}") - result = processor._clean_data(df) - processor.logger.debug(f"Test clean_data_normalize_text - output: {result['descricao'].tolist()}") - # Agora as descrições devem estar encapsuladas por aspas duplas e manter acentuação - assert all(x.startswith('"') and x.endswith('"') for x in result['descricao']) - -def test_transform_insumos(processor, sample_insumos_df): - """Testa transformação de dados de insumos.""" - result = processor._transform_insumos(sample_insumos_df) - assert 'CODIGO_INSUMO' in result.columns - assert 'DESCRICAO_INSUMO' in result.columns - assert 'PRECO_MEDIANO' in result.columns - assert result['PRECO_MEDIANO'].dtype in ['float64', 'float32'] - -def test_transform_composicoes(processor, sample_composicoes_df): - """Testa transformação de dados de composições.""" - result = processor._transform_composicoes(sample_composicoes_df) - assert 'CODIGO' in result.columns + result = processor._normalize_cols(df) + assert 'CODIGO_DO_ITEM' in result.columns assert 'DESCRICAO' in result.columns - assert 'CUSTO_TOTAL' in result.columns - assert result['CUSTO_TOTAL'].dtype in ['float64', 'float32'] - -def test_validate_data_empty_df(processor): - """Testa validação com DataFrame vazio.""" - df = pd.DataFrame() - - with pytest.raises(ProcessingError, match="DataFrame está vazio"): - processor._validate_data(df) - -def test_validate_data_invalid_codes(processor, sample_insumos_df): - """Testa validação de códigos inválidos.""" - # Cria uma cópia para não afetar o fixture - df = sample_insumos_df.copy() - df.loc[0, 'CODIGO'] = 'ABC' # Código inválido - # Ajusta para compatibilidade com o novo mapeamento - df = df.rename(columns={'CODIGO': 'codigo', 'DESCRICAO': 'descricao', 'UNIDADE': 'unidade', 'PRECO_MEDIANO': 'preco_mediano'}) - result = processor._validate_data(df) - # Só deve restar linhas com código numérico - assert all(result['codigo'].str.isnumeric()) - -def test_validate_data_negative_prices(processor, sample_insumos_df): - """Testa validação de preços negativos.""" - # Cria uma cópia para não afetar o fixture - df = sample_insumos_df.copy() - df.loc[0, 'PRECO_MEDIANO'] = -10.0 - df = df.rename(columns={'CODIGO': 'codigo', 'DESCRICAO': 'descricao', 'UNIDADE': 'unidade', 'PRECO_MEDIANO': 'preco_mediano'}) - result = processor._validate_data(df) - # Se houver linhas, o preço negativo deve ser None - if not result.empty: - assert result['preco_mediano'].isnull().iloc[0] + assert 'PRECO_UNITARIO' in result.columns -def test_validate_insumos_code_length(processor): - """Testa validação do tamanho dos códigos de insumos.""" +def test_process_composicao_itens(processor, tmp_path): + """Testa o processamento da estrutura das composições.""" + # Cria um arquivo XLSX de teste + test_file = tmp_path / "test_sinapi.xlsx" df = pd.DataFrame({ - 'CODIGO_INSUMO': ['123', '1234', '12345'], # Primeiro código inválido - 'DESCRICAO_INSUMO': ['A', 'B', 'C'] + 'CODIGO_DA_COMPOSICAO': ['87453', '87453'], + 'TIPO_ITEM': ['INSUMO', 'COMPOSICAO'], + 'CODIGO_DO_ITEM': ['1234', '5678'], + 'COEFICIENTE': ['1,0', '2,5'], + 'DESCRICAO': ['INSUMO A', 'COMPOSICAO B'], + 'UNIDADE': ['UN', 'M2'] }) - result = processor._validate_insumos(df) - # Aceita códigos com 4 ou mais dígitos - assert len(result) == 2 - assert set(result['CODIGO_INSUMO']) == {'1234', '12345'} - -def test_validate_composicoes_code_length(processor): - """Testa validação do tamanho dos códigos de composições.""" - df = pd.DataFrame({ - 'codigo': ['1234', '12345', '123456'], # Primeiro código inválido - 'descricao': ['A', 'B', 'C'] - }) - result = processor._validate_composicoes(df) - # Aceita códigos com exatamente 6 dígitos - assert all(result['codigo'].str.len() == 6) - assert set(result['codigo']) == {'123456'} - - -def test_process_composicao_itens(tmp_path): - """Testa o processamento da estrutura das composições e inserção na tabela composicao_itens.""" - import pandas as pd - from sqlalchemy.engine import create_engine, Connection, Engine - from sqlalchemy import text - # Cria DataFrame simulado - df = pd.DataFrame({ - 'CÓDIGO DA COMPOSIÇÃO': [1001, 1001, 1002], - 'CÓDIGO DO ITEM': [2001, 2002, 2003], - 'TIPO ITEM': ['INSUMO', 'COMPOSICAO', 'INSUMO'], - 'COEFICIENTE': ['1,5', '2.0', '0,75'] - }) - # Salva como xlsx temporário - xlsx_path = tmp_path / 'analitico.xlsx' - with pd.ExcelWriter(xlsx_path) as writer: - df.to_excel(writer, index=False, sheet_name='Analítico') - - # Cria engine SQLite em memória para teste - engine = create_engine('sqlite:///:memory:') - - # Cria tabela composicao_itens - with engine.connect() as conn: - conn.execute(text('''CREATE TABLE composicao_itens ( - composicao_pai_codigo INTEGER, - item_codigo INTEGER, - tipo_item TEXT, - coeficiente REAL - )''')) - conn.commit() - - # Processa os dados - processor = Processor({'year': 2025, 'month': 8, 'type': 'REFERENCIA'}) - processor.process_composicao_itens(str(xlsx_path), engine) - - # Verifica se os dados foram inseridos corretamente - result = pd.read_sql('SELECT * FROM composicao_itens ORDER BY composicao_pai_codigo', engine) - assert len(result) == 3 - assert set(result['tipo_item']) == {'INSUMO', 'COMPOSICAO'} - - + # Adiciona linha de cabeçalho e outras linhas para simular o arquivo real + writer = pd.ExcelWriter(test_file, engine='xlsxwriter') + df.to_excel(writer, index=False, header=True, sheet_name='Analítico', startrow=9) + writer.close() + + result = processor.process_composicao_itens(str(test_file)) + + assert 'composicao_insumos' in result + assert 'composicao_subcomposicoes' in result + assert len(result['composicao_insumos']) == 1 + assert len(result['composicao_subcomposicoes']) == 1 + assert result['composicao_insumos'].iloc[0]['insumo_filho_codigo'] == 1234 \ No newline at end of file diff --git a/tests/test_file_input.py b/tests/test_file_input.py index b95c03a..8009b0e 100644 --- a/tests/test_file_input.py +++ b/tests/test_file_input.py @@ -1,42 +1,48 @@ -def test_real_excel_input(tmp_path): - """Testa o pipeline com um arquivo Excel real do SINAPI.""" - import shutil - from autosinapi import run_etl - # Copia um arquivo real para o tmp_path para simular input do usuário - src_file = 'tools/downloads/2025_07/SINAPI-2025-07-formato-xlsx/SINAPI_mao_de_obra_2025_07.xlsx' - test_file = tmp_path / 'SINAPI_mao_de_obra_2025_07.xlsx' - shutil.copy(src_file, test_file) - - db_config = { - 'host': 'localhost', - 'port': 5432, - 'database': 'test_db', - 'user': 'test_user', - 'password': 'test_pass' - } - sinapi_config = { - 'state': 'SP', - 'month': '07', - 'year': '2025', - 'type': 'insumos', - 'input_file': str(test_file) - } - result = run_etl(db_config, sinapi_config, mode='server') - if result['status'] != 'success': - print('Erro no pipeline:', result) - assert result['status'] == 'success' - assert isinstance(result['details'].get('rows_processed', 1), int) """ Testes do módulo de download com suporte a input direto de arquivo. """ import pytest from pathlib import Path import pandas as pd -from autosinapi import run_etl +from unittest.mock import patch, MagicMock +from tools.autosinapi_pipeline import Pipeline + +@pytest.fixture +def mock_pipeline(mocker, tmp_path): + """Fixture para mockar o pipeline e suas dependências.""" + mocker.patch('tools.autosinapi_pipeline.setup_logging') + + # Cria um diretório de extração falso + extraction_path = tmp_path / "extraction" + extraction_path.mkdir() + # Cria um arquivo de referência falso dentro do diretório + (extraction_path / "SINAPI_Referência_2023_01.xlsx").touch() -def test_direct_file_input(tmp_path): + with patch('tools.autosinapi_pipeline.Database') as mock_db: + mock_db_instance = MagicMock() + mock_db.return_value = mock_db_instance + + with patch('tools.autosinapi_pipeline.Downloader') as mock_downloader: + mock_downloader_instance = MagicMock() + mock_downloader.return_value = mock_downloader_instance + + with patch('tools.autosinapi_pipeline.Processor') as mock_processor: + mock_processor_instance = MagicMock() + mock_processor.return_value = mock_processor_instance + + pipeline = Pipeline(config_path=None) + + mocker.patch.object(pipeline, '_run_pre_processing') + mocker.patch.object(pipeline, '_sync_catalog_status') + mocker.patch.object(pipeline, '_unzip_file', return_value=extraction_path) + mocker.patch.object(pipeline, '_find_and_normalize_zip', return_value=Path("mocked.zip")) + + yield pipeline, mock_db_instance, mock_downloader_instance, mock_processor_instance + +def test_direct_file_input(tmp_path, mock_pipeline): """Testa o pipeline com input direto de arquivo.""" - # Cria um arquivo XLSX de teste + pipeline, mock_db, _, mock_processor = mock_pipeline + test_file = tmp_path / "test_sinapi.xlsx" df = pd.DataFrame({ 'codigo': [1234, 5678], @@ -46,80 +52,82 @@ def test_direct_file_input(tmp_path): }) df.to_excel(test_file, index=False) - # Configura o teste db_config = { - 'host': 'localhost', - 'port': 5432, - 'database': 'test_db', - 'user': 'test_user', - 'password': 'test_pass' + 'host': 'localhost', 'port': 5432, 'database': 'test_db', + 'user': 'test_user', 'password': 'test_pass' } - sinapi_config = { - 'state': 'SP', - 'month': '01', - 'year': '2023', - 'type': 'insumos', - 'input_file': str(test_file) # Usa arquivo local + 'state': 'SP', 'month': '01', 'year': '2023', 'type': 'insumos', + 'input_file': str(test_file) } - - # Executa o pipeline - result = run_etl(db_config, sinapi_config, mode='server') - - # Verifica o resultado - assert result['status'] == 'success' - assert result['details']['rows_processed'] == 2 - assert isinstance(result['details']['timestamp'], str) -def test_fallback_to_download(mocker): + with patch.object(pipeline, '_load_config', return_value={ + "secrets_path": "dummy_path", + "default_year": "2023", + "default_month": "01", + }): + with patch.object(pipeline, '_get_db_config', return_value=db_config): + with patch.object(pipeline, '_get_sinapi_config', return_value=sinapi_config): + mock_processor.process_catalogo_e_precos.return_value = {'insumos': df} + mock_processor.process_composicao_itens.return_value = { + 'composicao_insumos': pd.DataFrame(columns=['insumo_filho_codigo']), + 'composicao_subcomposicoes': pd.DataFrame(), + 'parent_composicoes_details': pd.DataFrame(columns=['codigo', 'descricao', 'unidade']), + 'child_item_details': pd.DataFrame(columns=['codigo', 'tipo', 'descricao', 'unidade']) + } + + pipeline.run() + + mock_processor.process_catalogo_e_precos.assert_called() + mock_db.save_data.assert_called() + +def test_fallback_to_download(mock_pipeline): """Testa o fallback para download quando arquivo não é fornecido.""" - # Mock do downloader - mock_download = mocker.patch('autosinapi.core.downloader.Downloader._download_file') - mock_download.return_value = mocker.Mock() + pipeline, _, mock_downloader, _ = mock_pipeline db_config = { - 'host': 'localhost', - 'port': 5432, - 'database': 'test_db', - 'user': 'test_user', - 'password': 'test_pass' + 'host': 'localhost', 'port': 5432, 'database': 'test_db', + 'user': 'test_user', 'password': 'test_pass' } - sinapi_config = { - 'state': 'SP', - 'month': '01', - 'year': '2023', - 'type': 'insumos' - # Sem input_file, deve tentar download + 'state': 'SP', 'month': '01', 'year': '2023', 'type': 'insumos' } - - # Executa o pipeline - result = run_etl(db_config, sinapi_config, mode='server') - - # Verifica se o download foi tentado - mock_download.assert_called_once() -def test_invalid_input_file(): + with patch.object(pipeline, '_load_config', return_value={ + "secrets_path": "dummy_path", + "default_year": "2023", + "default_month": "01", + }): + with patch.object(pipeline, '_get_db_config', return_value=db_config): + with patch.object(pipeline, '_get_sinapi_config', return_value=sinapi_config): + pipeline._find_and_normalize_zip.return_value = None + + pipeline.run() + + mock_downloader.get_sinapi_data.assert_called_once() + +def test_invalid_input_file(mock_pipeline, caplog): """Testa erro ao fornecer arquivo inválido.""" + pipeline, _, _, _ = mock_pipeline + db_config = { - 'host': 'localhost', - 'port': 5432, - 'database': 'test_db', - 'user': 'test_user', - 'password': 'test_pass' + 'host': 'localhost', 'port': 5432, 'database': 'test_db', + 'user': 'test_user', 'password': 'test_pass' } - sinapi_config = { - 'state': 'SP', - 'month': '01', - 'year': '2023', - 'type': 'insumos', + 'state': 'SP', 'month': '01', 'year': '2023', 'type': 'insumos', 'input_file': 'arquivo_inexistente.xlsx' } - - # Executa o pipeline - result = run_etl(db_config, sinapi_config, mode='server') - - # Verifica se retornou erro - assert result['status'] == 'error' - assert 'Arquivo não encontrado' in result['message'] + + with patch.object(pipeline, '_load_config', return_value={ + "secrets_path": "dummy_path", + "default_year": "2023", + "default_month": "01", + }): + with patch.object(pipeline, '_get_db_config', return_value=db_config): + with patch.object(pipeline, '_get_sinapi_config', return_value=sinapi_config): + pipeline._unzip_file.side_effect = FileNotFoundError("Arquivo não encontrado") + + pipeline.run() + + assert "Arquivo não encontrado" in caplog.text diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 44b090d..4efe76c 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -2,154 +2,117 @@ Testes de integração para o pipeline principal do AutoSINAPI. """ import pytest -from unittest.mock import Mock, patch +from unittest.mock import patch, MagicMock import pandas as pd -from autosinapi import run_etl -from autosinapi.core.downloader import Downloader -from autosinapi.core.processor import Processor -from autosinapi.core.database import Database -from autosinapi.exceptions import ( - AutoSINAPIError, - DownloadError, - ProcessingError, - DatabaseError -) +from pathlib import Path +from tools.autosinapi_pipeline import Pipeline +from autosinapi.exceptions import DownloadError, ProcessingError, DatabaseError @pytest.fixture def db_config(): """Fixture com configurações do banco de dados.""" return { - 'host': 'localhost', - 'port': 5432, - 'database': 'test_db', - 'user': 'test_user', - 'password': 'test_pass' + 'host': 'localhost', 'port': 5432, 'database': 'test_db', + 'user': 'test_user', 'password': 'test_pass' } @pytest.fixture def sinapi_config(): """Fixture com configurações do SINAPI.""" return { - 'state': 'SP', - 'year': 2025, - 'month': 8, - 'type': 'REFERENCIA', + 'state': 'SP', 'year': 2025, 'month': 8, 'type': 'REFERENCIA', 'duplicate_policy': 'substituir' } @pytest.fixture -def mock_data(): - """Fixture com dados de exemplo.""" - return pd.DataFrame({ - 'CODIGO': ['1234', '5678'], - 'DESCRICAO': ['Item A', 'Item B'], - 'PRECO': [100.0, 200.0] - }) - -def test_run_etl_success_real(db_config, sinapi_config, tmp_path): - """Testa o fluxo completo do ETL com um arquivo real do SINAPI.""" - import shutil - import pandas as pd - from unittest.mock import patch, MagicMock - # Copia um arquivo real para o tmp_path - src_file = 'tools/downloads/2025_07/SINAPI-2025-07-formato-xlsx/SINAPI_mao_de_obra_2025_07.xlsx' - test_file = tmp_path / 'SINAPI_mao_de_obra_2025_07.xlsx' - shutil.copy(src_file, test_file) - # Atualiza config para usar o arquivo real - sinapi_config = sinapi_config.copy() - sinapi_config['input_file'] = str(test_file) - sinapi_config['type'] = 'insumos' - # Tenta rodar com arquivo real - with patch('autosinapi.core.database.Database') as mock_db, \ - patch('autosinapi.core.database.create_engine') as mock_engine: - mock_db_instance = MagicMock() - mock_db_instance.save_data.return_value = None - mock_db.return_value = mock_db_instance - mock_engine.return_value = MagicMock() - result = run_etl(db_config, sinapi_config, mode='server') - if result['status'] == 'success': - assert isinstance(result['details'].get('rows_processed', 1), int) - return - # Se falhar por campos obrigatórios, tenta fixture sintética - if 'Campos obrigatórios ausentes' in result.get('message', ''): - # Cria DataFrame sintético compatível - df = pd.DataFrame({ - 'codigo': ['1234', '5678'], - 'descricao': ['"Areia Média"', '"Cimento Portland"'], - 'unidade': ['"M3"', '"KG"'], - 'preco_mediano': [120.5, 0.89] - }) - fake_file = tmp_path / 'fake_insumos.xlsx' - df.to_excel(fake_file, index=False) - sinapi_config['input_file'] = str(fake_file) - result = run_etl(db_config, sinapi_config, mode='server') - if result['status'] != 'success': - print('Erro no pipeline (fixture sintética):', result) - assert result['status'] == 'success' - assert isinstance(result['details'].get('rows_processed', 1), int) - else: - print('Erro no pipeline:', result) - assert False, f"Pipeline falhou: {result}" - -def test_run_etl_download_error(db_config, sinapi_config): - """Testa falha no download.""" - # Testa erro real de download (sem input_file e mês/ano inexistente) - sinapi_config = sinapi_config.copy() - sinapi_config['month'] = 1 - sinapi_config['year'] = 1900 # Data impossível - from unittest.mock import patch, MagicMock - with patch('autosinapi.core.database.Database') as mock_db: - mock_db_instance = MagicMock() - mock_db_instance.save_data.return_value = None - mock_db.return_value = mock_db_instance - result = run_etl(db_config, sinapi_config, mode='server') - assert result['status'] == 'error' - assert 'download' in result['message'].lower() or 'não encontrado' in result['message'].lower() or 'salvar dados' in result['message'].lower() +def mock_pipeline(mocker, db_config, sinapi_config, tmp_path): + """Fixture para mockar o pipeline e suas dependências.""" + mocker.patch('tools.autosinapi_pipeline.setup_logging') + + # Cria um diretório de extração falso + extraction_path = tmp_path / "extraction" + extraction_path.mkdir() + # Cria um arquivo de referência falso dentro do diretório + (extraction_path / "SINAPI_Referência_2025_08.xlsx").touch() -def test_run_etl_processing_error(db_config, sinapi_config): - """Testa falha no processamento.""" - # Testa erro real de processamento: arquivo Excel inválido - import tempfile - from unittest.mock import patch, MagicMock - with tempfile.NamedTemporaryFile(suffix='.xlsx') as f: - sinapi_config = sinapi_config.copy() - sinapi_config['input_file'] = f.name - with patch('autosinapi.core.database.Database') as mock_db: - mock_db_instance = MagicMock() - mock_db_instance.save_data.return_value = None - mock_db.return_value = mock_db_instance - result = run_etl(db_config, sinapi_config, mode='server') - assert result['status'] == 'error' - assert 'processamento' in result['message'].lower() or 'arquivo' in result['message'].lower() - -def test_run_etl_database_error(db_config, sinapi_config, mock_data): - """Testa falha no banco de dados.""" - # Teste de erro de banco: simula config inválida - from unittest.mock import patch, MagicMock - db_config = db_config.copy() - db_config['port'] = 9999 # Porta inválida - with patch('autosinapi.core.database.Database') as mock_db: + with patch('tools.autosinapi_pipeline.Database') as mock_db, \ + patch('tools.autosinapi_pipeline.Downloader') as mock_downloader, \ + patch('tools.autosinapi_pipeline.Processor') as mock_processor: + mock_db_instance = MagicMock() - mock_db_instance.save_data.side_effect = Exception("Erro simulado de banco de dados") mock_db.return_value = mock_db_instance - result = run_etl(db_config, sinapi_config, mode='server') - assert result['status'] == 'error' - assert 'banco de dados' in result['message'].lower() or 'conex' in result['message'].lower() or 'salvar dados' in result['message'].lower() + + mock_downloader_instance = MagicMock() + mock_downloader.return_value = mock_downloader_instance + + mock_processor_instance = MagicMock() + mock_processor.return_value = mock_processor_instance + + pipeline = Pipeline(config_path=None) + + mocker.patch.object(pipeline, '_get_db_config', return_value=db_config) + mocker.patch.object(pipeline, '_get_sinapi_config', return_value=sinapi_config) + mocker.patch.object(pipeline, '_load_config', return_value={ + "secrets_path": "dummy", + "default_year": sinapi_config['year'], + "default_month": sinapi_config['month'] + }) + + mocker.patch.object(pipeline, '_find_and_normalize_zip', return_value=MagicMock()) + mocker.patch.object(pipeline, '_unzip_file', return_value=extraction_path) + mocker.patch.object(pipeline, '_run_pre_processing') + mocker.patch.object(pipeline, '_sync_catalog_status') + + yield pipeline, mock_db_instance, mock_downloader_instance, mock_processor_instance + +def test_run_etl_success(mock_pipeline): + """Testa o fluxo completo do ETL com sucesso.""" + pipeline, mock_db, _, mock_processor = mock_pipeline + + mock_processor.process_catalogo_e_precos.return_value = { + 'insumos': pd.DataFrame({'codigo': ['1'], 'descricao': ['a'], 'unidade': ['un']}), + 'composicoes': pd.DataFrame({'codigo': ['c1'], 'descricao': ['ca'], 'unidade': ['un']}) + } + mock_processor.process_composicao_itens.return_value = { + 'composicao_insumos': pd.DataFrame({'insumo_filho_codigo': ['1']}), + 'composicao_subcomposicoes': pd.DataFrame(), + 'parent_composicoes_details': pd.DataFrame({'codigo': ['c1'], 'descricao': ['ca'], 'unidade': ['un']}), + 'child_item_details': pd.DataFrame({'codigo': ['1'], 'tipo': ['INSUMO'], 'descricao': ['a'], 'unidade': ['un']}) + } + + pipeline.run() + + mock_db.create_tables.assert_called_once() + mock_processor.process_catalogo_e_precos.assert_called() + assert mock_db.save_data.call_count > 0 -def test_run_etl_invalid_mode(db_config, sinapi_config): - """Testa modo de operação inválido.""" - result = run_etl(db_config, sinapi_config, mode='invalid') +def test_run_etl_download_error(mock_pipeline, caplog): + """Testa falha no download.""" + pipeline, _, mock_downloader, _ = mock_pipeline - assert result['status'] == 'error' - assert 'modo' in result['message'].lower() + pipeline._find_and_normalize_zip.return_value = None + mock_downloader.get_sinapi_data.side_effect = DownloadError("Network error") + + pipeline.run() + + assert "Erro de negócio no pipeline AutoSINAPI: Network error" in caplog.text -def test_run_etl_invalid_config(db_config, sinapi_config): - """Testa configurações inválidas.""" - # Remove campo obrigatório - del db_config['host'] +def test_run_etl_processing_error(mock_pipeline, caplog): + """Testa falha no processamento.""" + pipeline, _, _, mock_processor = mock_pipeline - result = run_etl(db_config, sinapi_config, mode='server') + mock_processor.process_catalogo_e_precos.side_effect = ProcessingError("Invalid format") + + pipeline.run() + + assert "Erro de negócio no pipeline AutoSINAPI: Invalid format" in caplog.text + +def test_run_etl_database_error(mock_pipeline, caplog): + """Testa falha no banco de dados.""" + pipeline, mock_db, _, _ = mock_pipeline - assert result['status'] == 'error' - msg = result['message'].lower() - assert 'configuração' in msg or 'configurações' in msg + mock_db.create_tables.side_effect = DatabaseError("Connection failed") + + pipeline.run() + + assert "Erro de negócio no pipeline AutoSINAPI: Connection failed" in caplog.text