From 143ec401f5ed738d6bbdc0fb21ce0e79934e1686 Mon Sep 17 00:00:00 2001 From: Frederico Castro Date: Mon, 29 Dec 2025 08:06:08 -0300 Subject: [PATCH] feat(tests): adicionar suite completa de testes automatizados MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 198 testes cobrindo todos os módulos do backend - Testes unitários para calculador de pontuação (56 testes) - Testes para value objects de período (23 testes) - Testes para cliente Elasticsearch com mocks (27 testes) - Testes para repository de consultores (48 testes) - Testes de integração ES + Repository (6 testes) - Testes para API routes FastAPI (23 testes) - Testes para job de processamento (16 testes) - Cobertura de 54% do código --- backend/tests/__init__.py | 0 backend/tests/application/__init__.py | 0 backend/tests/application/jobs/__init__.py | 0 .../jobs/test_processar_ranking.py | 278 ++++++++ backend/tests/conftest.py | 294 +++++++++ backend/tests/domain/__init__.py | 0 backend/tests/domain/services/__init__.py | 0 .../services/test_calculador_pontuacao.py | 599 ++++++++++++++++++ .../tests/domain/value_objects/__init__.py | 0 .../domain/value_objects/test_periodo.py | 169 +++++ backend/tests/infrastructure/__init__.py | 0 .../elasticsearch/test_client.py | 508 +++++++++++++++ .../infrastructure/repositories/__init__.py | 0 .../test_consultor_repository_impl.py | 483 ++++++++++++++ .../test_es_repository_integration.py | 125 ++++ backend/tests/integration/test_scroll_flow.py | 142 +++++ backend/tests/interface/__init__.py | 0 backend/tests/interface/api/__init__.py | 0 backend/tests/interface/api/test_routes.py | 301 +++++++++ 19 files changed, 2899 insertions(+) create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/application/__init__.py create mode 100644 backend/tests/application/jobs/__init__.py create mode 100644 backend/tests/application/jobs/test_processar_ranking.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/domain/__init__.py create mode 100644 backend/tests/domain/services/__init__.py create mode 100644 backend/tests/domain/services/test_calculador_pontuacao.py create mode 100644 backend/tests/domain/value_objects/__init__.py create mode 100644 backend/tests/domain/value_objects/test_periodo.py create mode 100644 backend/tests/infrastructure/__init__.py create mode 100644 backend/tests/infrastructure/elasticsearch/test_client.py create mode 100644 backend/tests/infrastructure/repositories/__init__.py create mode 100644 backend/tests/infrastructure/repositories/test_consultor_repository_impl.py create mode 100644 backend/tests/integration/test_es_repository_integration.py create mode 100644 backend/tests/integration/test_scroll_flow.py create mode 100644 backend/tests/interface/__init__.py create mode 100644 backend/tests/interface/api/__init__.py create mode 100644 backend/tests/interface/api/test_routes.py diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/application/__init__.py b/backend/tests/application/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/application/jobs/__init__.py b/backend/tests/application/jobs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/application/jobs/test_processar_ranking.py b/backend/tests/application/jobs/test_processar_ranking.py new file mode 100644 index 0000000..606343b --- /dev/null +++ b/backend/tests/application/jobs/test_processar_ranking.py @@ -0,0 +1,278 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock, patch +from datetime import datetime + +from src.application.jobs.processar_ranking import ProcessarRankingJob +from src.infrastructure.ranking_store import RankingEntry + + +@pytest.fixture +def mock_es_client(): + client = AsyncMock() + client.contar_com_atuacoes.return_value = 100 + client.buscar_todos_consultores.return_value = {"processados": 100, "batches": 10} + return client + + +@pytest.fixture +def mock_ranking_store(): + store = AsyncMock() + store.set_entries = AsyncMock() + return store + + +@pytest.fixture +def mock_oracle_repo(): + repo = MagicMock() + repo.limpar_tabela = MagicMock() + repo.inserir_batch = MagicMock() + repo.atualizar_posicoes = MagicMock() + return repo + + +@pytest.fixture +def job(mock_es_client, mock_ranking_store, mock_oracle_repo): + return ProcessarRankingJob( + es_client=mock_es_client, + ranking_store=mock_ranking_store, + ranking_oracle_repo=mock_oracle_repo, + ) + + +class TestGerarEntriesOrdenadas: + + def test_lista_vazia(self): + entries = ProcessarRankingJob._gerar_entries_ordenadas([]) + assert len(entries) == 0 + + def test_ordenacao_por_pontuacao(self): + consultores = [ + {"id_pessoa": 1, "nome": "A", "pontuacao_total": 100, "bloco_a": 50, "bloco_b": 20, "bloco_c": 20, "bloco_d": 5, "bloco_e": 5, "ativo": True, "anos_atuacao": 5}, + {"id_pessoa": 2, "nome": "B", "pontuacao_total": 300, "bloco_a": 150, "bloco_b": 80, "bloco_c": 50, "bloco_d": 10, "bloco_e": 10, "ativo": True, "anos_atuacao": 10}, + {"id_pessoa": 3, "nome": "C", "pontuacao_total": 200, "bloco_a": 100, "bloco_b": 50, "bloco_c": 30, "bloco_d": 10, "bloco_e": 10, "ativo": False, "anos_atuacao": 3}, + ] + entries = ProcessarRankingJob._gerar_entries_ordenadas(consultores) + assert len(entries) == 3 + assert entries[0].id_pessoa == 2 + assert entries[0].posicao == 1 + assert entries[0].pontuacao_total == 300 + assert entries[1].id_pessoa == 3 + assert entries[1].posicao == 2 + assert entries[2].id_pessoa == 1 + assert entries[2].posicao == 3 + + def test_posicoes_atribuidas_sequencialmente(self): + consultores = [ + {"id_pessoa": i, "nome": f"C{i}", "pontuacao_total": 100 - i, "bloco_a": 50, "bloco_b": 20, "bloco_c": 20, "bloco_d": 5, "bloco_e": 5, "ativo": True, "anos_atuacao": 5} + for i in range(1, 11) + ] + entries = ProcessarRankingJob._gerar_entries_ordenadas(consultores) + for i, entry in enumerate(entries, start=1): + assert entry.posicao == i + + def test_empate_desempata_por_id(self): + consultores = [ + {"id_pessoa": 10, "nome": "A", "pontuacao_total": 100, "bloco_a": 50, "bloco_b": 20, "bloco_c": 20, "bloco_d": 5, "bloco_e": 5, "ativo": True, "anos_atuacao": 5}, + {"id_pessoa": 5, "nome": "B", "pontuacao_total": 100, "bloco_a": 50, "bloco_b": 20, "bloco_c": 20, "bloco_d": 5, "bloco_e": 5, "ativo": True, "anos_atuacao": 5}, + ] + entries = ProcessarRankingJob._gerar_entries_ordenadas(consultores) + assert entries[0].id_pessoa == 5 + assert entries[1].id_pessoa == 10 + + +class TestObterEstatisticas: + + def test_lista_vazia(self): + stats = ProcessarRankingJob._obter_estatisticas([]) + assert stats["total_consultores"] == 0 + assert stats["pontuacao_media"] == 0 + assert stats["pontuacao_maxima"] == 0 + + def test_estatisticas_calculadas(self): + entries = [ + RankingEntry(id_pessoa=1, nome="A", posicao=1, pontuacao_total=300, bloco_a=100, bloco_b=80, bloco_c=60, bloco_d=40, bloco_e=20, ativo=True, anos_atuacao=5, detalhes={}), + RankingEntry(id_pessoa=2, nome="B", posicao=2, pontuacao_total=200, bloco_a=80, bloco_b=50, bloco_c=40, bloco_d=20, bloco_e=10, ativo=True, anos_atuacao=3, detalhes={}), + RankingEntry(id_pessoa=3, nome="C", posicao=3, pontuacao_total=100, bloco_a=40, bloco_b=30, bloco_c=20, bloco_d=5, bloco_e=5, ativo=False, anos_atuacao=2, detalhes={}), + ] + stats = ProcessarRankingJob._obter_estatisticas(entries) + assert stats["total_consultores"] == 3 + assert stats["total_ativos"] == 2 + assert stats["total_inativos"] == 1 + assert stats["pontuacao_maxima"] == 300 + assert stats["pontuacao_minima"] == 100 + assert stats["pontuacao_media"] == 200.0 + + def test_media_componentes(self): + entries = [ + RankingEntry(id_pessoa=1, nome="A", posicao=1, pontuacao_total=300, bloco_a=100, bloco_b=50, bloco_c=80, bloco_d=40, bloco_e=30, ativo=True, anos_atuacao=5, detalhes={}), + RankingEntry(id_pessoa=2, nome="B", posicao=2, pontuacao_total=200, bloco_a=80, bloco_b=30, bloco_c=60, bloco_d=20, bloco_e=10, ativo=True, anos_atuacao=3, detalhes={}), + ] + stats = ProcessarRankingJob._obter_estatisticas(entries) + assert stats["media_componentes"]["a"] == 90.0 + assert stats["media_componentes"]["b"] == 40.0 + + +class TestGerarJsonDetalhes: + + def test_gerar_json_consultor_completo(self, job): + from src.domain.entities.consultor import Consultor, CoordenacaoCapes, Consultoria + from src.domain.value_objects.periodo import Periodo + from src.domain.value_objects.pontuacao import PontuacaoBloco, PontuacaoCompleta + + periodo = Periodo(inicio=datetime(2020, 1, 1), fim=None) + consultor = Consultor( + id_pessoa=123, + nome="João Silva", + coordenacoes_capes=[ + CoordenacaoCapes( + codigo="CA", + tipo="Coordenador de Área", + area_avaliacao="CIÊNCIAS", + periodo=periodo, + ) + ], + consultoria=Consultoria( + codigo="CONS_ATIVO", + situacao="Atividade Contínua", + periodo=periodo, + anos_consecutivos=5, + retornos=0, + ), + ) + consultor.pontuacao = PontuacaoCompleta( + bloco_a=PontuacaoBloco(bloco="A", atuacoes=[]), + bloco_b=PontuacaoBloco(bloco="B", atuacoes=[]), + bloco_c=PontuacaoBloco(bloco="C", atuacoes=[]), + bloco_d=PontuacaoBloco(bloco="D", atuacoes=[]), + bloco_e=PontuacaoBloco(bloco="E", atuacoes=[]), + ) + + result = job._gerar_json_detalhes(consultor) + + assert result["id_pessoa"] == 123 + assert result["nome"] == "João Silva" + assert len(result["coordenacoes_capes"]) == 1 + assert result["consultoria"] is not None + assert result["consultoria"]["codigo"] == "CONS_ATIVO" + + def test_gerar_json_consultor_sem_consultoria(self, job): + from src.domain.entities.consultor import Consultor + from src.domain.value_objects.pontuacao import PontuacaoBloco, PontuacaoCompleta + + consultor = Consultor( + id_pessoa=456, + nome="Maria Santos", + ) + consultor.pontuacao = PontuacaoCompleta( + bloco_a=PontuacaoBloco(bloco="A", atuacoes=[]), + bloco_b=PontuacaoBloco(bloco="B", atuacoes=[]), + bloco_c=PontuacaoBloco(bloco="C", atuacoes=[]), + bloco_d=PontuacaoBloco(bloco="D", atuacoes=[]), + bloco_e=PontuacaoBloco(bloco="E", atuacoes=[]), + ) + + result = job._gerar_json_detalhes(consultor) + + assert result["id_pessoa"] == 456 + assert result["consultoria"] is None + + +class TestProcessarBatch: + + @pytest.mark.asyncio + async def test_processar_batch_atualiza_progresso(self, job): + with patch.object(job.consultor_repo, "_construir_consultor") as mock_construir: + from src.domain.entities.consultor import Consultor + from src.domain.value_objects.pontuacao import PontuacaoBloco, PontuacaoCompleta + + consultor = Consultor(id_pessoa=1, nome="Test") + consultor.pontuacao = PontuacaoCompleta( + bloco_a=PontuacaoBloco(bloco="A", atuacoes=[]), + bloco_b=PontuacaoBloco(bloco="B", atuacoes=[]), + bloco_c=PontuacaoBloco(bloco="C", atuacoes=[]), + bloco_d=PontuacaoBloco(bloco="D", atuacoes=[]), + bloco_e=PontuacaoBloco(bloco="E", atuacoes=[]), + ) + mock_construir.return_value = consultor + + docs = [{"id": 1, "dadosPessoais": {"nome": "Test"}, "atuacoes": []}] + progress = {"processados": 1, "batch_atual": 1, "percentual": 10} + + with patch("src.application.jobs.processar_ranking.job_status"): + await job._processar_batch(docs, progress) + + assert len(job._consultores) == 1 + + +class TestExecutar: + + @pytest.mark.asyncio + async def test_executar_job_ja_rodando(self, job): + with patch("src.application.jobs.processar_ranking.job_status") as mock_status: + mock_status.is_running = True + + with pytest.raises(RuntimeError, match="Job já está em execução"): + await job.executar() + + @pytest.mark.asyncio + async def test_executar_sucesso(self, job, mock_es_client, mock_ranking_store): + with patch("src.application.jobs.processar_ranking.job_status") as mock_status: + mock_status.is_running = False + mock_status.iniciar = MagicMock() + mock_status.atualizar_progresso = MagicMock() + mock_status.finalizar = MagicMock() + mock_status.mensagem = "" + mock_status.tempo_decorrido = 10.5 + + job._consultores = [ + {"id_pessoa": 1, "nome": "Test", "pontuacao_total": 100, "bloco_a": 50, "bloco_b": 20, "bloco_c": 20, "bloco_d": 5, "bloco_e": 5, "ativo": True, "anos_atuacao": 5} + ] + + resultado = await job.executar() + + assert resultado["sucesso"] is True + mock_status.iniciar.assert_called_once() + mock_status.finalizar.assert_called_once_with(sucesso=True) + + @pytest.mark.asyncio + async def test_executar_erro(self, job, mock_es_client): + with patch("src.application.jobs.processar_ranking.job_status") as mock_status: + mock_status.is_running = False + mock_status.iniciar = MagicMock() + mock_status.finalizar = MagicMock() + + mock_es_client.contar_com_atuacoes.side_effect = Exception("ES Error") + + with pytest.raises(RuntimeError): + await job.executar() + + mock_status.finalizar.assert_called_once() + args = mock_status.finalizar.call_args + assert args[1]["sucesso"] is False + + +class TestPersistirOracle: + + @pytest.mark.asyncio + async def test_persistir_oracle_limpa_antes(self, job, mock_oracle_repo): + consultores = [ + {"id_pessoa": 1, "nome": "Test", "pontuacao_total": 100, "posicao": 1} + ] + await job._persistir_oracle(consultores, limpar_antes=True) + mock_oracle_repo.limpar_tabela.assert_called_once() + mock_oracle_repo.inserir_batch.assert_called() + mock_oracle_repo.atualizar_posicoes.assert_called_once() + + @pytest.mark.asyncio + async def test_persistir_oracle_sem_limpar(self, job, mock_oracle_repo): + consultores = [ + {"id_pessoa": 1, "nome": "Test", "pontuacao_total": 100, "posicao": 1} + ] + await job._persistir_oracle(consultores, limpar_antes=False) + mock_oracle_repo.limpar_tabela.assert_not_called() + + @pytest.mark.asyncio + async def test_persistir_oracle_em_batches(self, job, mock_oracle_repo): + consultores = [{"id_pessoa": i, "nome": f"Test{i}", "pontuacao_total": 100} for i in range(5000)] + await job._persistir_oracle(consultores, limpar_antes=True) + assert mock_oracle_repo.inserir_batch.call_count == 3 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..0996cb0 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,294 @@ +import pytest +from datetime import datetime +from dateutil.relativedelta import relativedelta + +from src.domain.entities.consultor import ( + Consultor, + CoordenacaoCapes, + Consultoria, + Inscricao, + AvaliacaoComissao, + Premiacao, + BolsaCNPQ, + Participacao, + Orientacao, + MembroBanca, +) +from src.domain.value_objects.periodo import Periodo + + +@pytest.fixture +def data_referencia(): + return datetime(2025, 1, 1) + + +@pytest.fixture +def hoje(): + return datetime.now() + + +def criar_periodo(anos_atras: int, ativo: bool = False, duracao_anos: int = 0) -> Periodo: + inicio = datetime.now() - relativedelta(years=anos_atras) + if ativo: + fim = None + else: + fim = inicio + relativedelta(years=duracao_anos) if duracao_anos > 0 else datetime.now() + return Periodo(inicio=inicio, fim=fim) + + +@pytest.fixture +def periodo_ativo_5_anos(): + return criar_periodo(anos_atras=5, ativo=True) + + +@pytest.fixture +def periodo_historico_3_anos(): + return criar_periodo(anos_atras=5, duracao_anos=3) + + +@pytest.fixture +def periodo_historico_10_anos(): + return criar_periodo(anos_atras=12, duracao_anos=10) + + +@pytest.fixture +def coordenacao_ca_ativa(periodo_ativo_5_anos): + return CoordenacaoCapes( + codigo="CA", + tipo="Coordenador de Área", + area_avaliacao="CIÊNCIAS AMBIENTAIS", + periodo=periodo_ativo_5_anos, + ) + + +@pytest.fixture +def coordenacao_ca_historica(periodo_historico_3_anos): + return CoordenacaoCapes( + codigo="CA", + tipo="Coordenador de Área", + area_avaliacao="MEDICINA I", + periodo=periodo_historico_3_anos, + ) + + +@pytest.fixture +def coordenacao_caj_ativa(): + return CoordenacaoCapes( + codigo="CAJ", + tipo="Coordenador Adjunto", + area_avaliacao="ENGENHARIA I", + periodo=criar_periodo(anos_atras=3, ativo=True), + ) + + +@pytest.fixture +def coordenacao_caj_mp_historica(): + return CoordenacaoCapes( + codigo="CAJ_MP", + tipo="Coordenador Adjunto de Mestrado Profissionalizante", + area_avaliacao="ADMINISTRAÇÃO", + periodo=criar_periodo(anos_atras=6, duracao_anos=4), + ) + + +@pytest.fixture +def coordenacao_cam_ativa(): + return CoordenacaoCapes( + codigo="CAM", + tipo="Câmara Temática", + area_avaliacao="INTERDISCIPLINAR", + periodo=criar_periodo(anos_atras=2, ativo=True), + ) + + +@pytest.fixture +def consultoria_ativa_8_anos(): + return Consultoria( + codigo="CONS_ATIVO", + situacao="Atividade Contínua", + periodo=criar_periodo(anos_atras=8, ativo=True), + periodos=[criar_periodo(anos_atras=8, ativo=True)], + anos_consecutivos=8, + retornos=0, + ) + + +@pytest.fixture +def consultoria_historica_5_anos(): + return Consultoria( + codigo="CONS_HIST", + situacao="Inativo", + periodo=criar_periodo(anos_atras=7, duracao_anos=5), + periodos=[criar_periodo(anos_atras=7, duracao_anos=5)], + anos_consecutivos=5, + retornos=0, + ) + + +@pytest.fixture +def consultoria_com_retorno(): + periodo1 = criar_periodo(anos_atras=10, duracao_anos=3) + periodo2 = criar_periodo(anos_atras=4, ativo=True) + return Consultoria( + codigo="CONS_ATIVO", + situacao="Atividade Contínua", + periodo=periodo2, + periodos=[periodo1, periodo2], + anos_consecutivos=4, + retornos=1, + ) + + +@pytest.fixture +def inscricao_autor(): + return Inscricao( + codigo="INSC_AUTOR", + tipo="Autor", + premio="PCT", + ano=2024, + situacao="Aprovada", + ) + + +@pytest.fixture +def inscricao_institucional(): + return Inscricao( + codigo="INSC_INST_AUTOR", + tipo="Institucional", + premio="PCT", + ano=2024, + situacao="Aprovada", + ) + + +@pytest.fixture +def avaliacao_comissao_premio(): + return AvaliacaoComissao( + codigo="AVAL_COMIS_PREMIO", + tipo="Membro de Comissão", + premio="PCT", + ano=2024, + ) + + +@pytest.fixture +def avaliacao_comissao_gp(): + return AvaliacaoComissao( + codigo="AVAL_COMIS_GP", + tipo="Membro de Comissão", + premio="Grande Prêmio", + ano=2024, + ) + + +@pytest.fixture +def coord_comissao_premio(): + return AvaliacaoComissao( + codigo="COORD_COMIS_PREMIO", + tipo="Coordenador/Presidente", + premio="PCT", + ano=2024, + comissao_tipo="Coordenador", + ) + + +@pytest.fixture +def premiacao_gp_autor(): + return Premiacao( + codigo="PREMIACAO_GP_AUTOR", + tipo="Grande Prêmio", + nome_premio="Grande Prêmio CAPES de Tese", + ano=2024, + papel="Autor", + ) + + +@pytest.fixture +def premiacao_autor(): + return Premiacao( + codigo="PREMIACAO_AUTOR", + tipo="Prêmio", + nome_premio="Prêmio CAPES de Tese", + ano=2024, + papel="Autor", + ) + + +@pytest.fixture +def mencao_autor(): + return Premiacao( + codigo="MENCAO_AUTOR", + tipo="Menção Honrosa", + nome_premio="Prêmio CAPES de Tese", + ano=2024, + papel="Autor", + ) + + +@pytest.fixture +def bolsa_cnpq(): + return BolsaCNPQ( + codigo="BOL_BPQ_NIVEL", + nivel="1A", + area="Ciências Exatas", + ) + + +@pytest.fixture +def participacao_evento(): + return Participacao( + codigo="EVENTO", + tipo="Evento", + descricao="Seminário CAPES 2024", + ano=2024, + ) + + +@pytest.fixture +def participacao_projeto(): + return Participacao( + codigo="PROJ", + tipo="Projeto", + descricao="Projeto de Pesquisa", + ano=2024, + ) + + +@pytest.fixture +def consultor_vazio(): + return Consultor( + id_pessoa=1, + nome="Consultor Vazio", + ) + + +@pytest.fixture +def consultor_coordenador_area(coordenacao_ca_ativa): + return Consultor( + id_pessoa=2, + nome="Coordenador de Área Ativo", + coordenacoes_capes=[coordenacao_ca_ativa], + ) + + +@pytest.fixture +def consultor_completo( + coordenacao_ca_ativa, + consultoria_ativa_8_anos, + inscricao_autor, + avaliacao_comissao_premio, + premiacao_autor, + bolsa_cnpq, + participacao_evento, +): + return Consultor( + id_pessoa=3, + nome="Consultor Completo", + coordenacoes_capes=[coordenacao_ca_ativa], + consultoria=consultoria_ativa_8_anos, + inscricoes=[inscricao_autor], + avaliacoes_comissao=[avaliacao_comissao_premio], + premiacoes=[premiacao_autor], + bolsas_cnpq=[bolsa_cnpq], + participacoes=[participacao_evento], + ) diff --git a/backend/tests/domain/__init__.py b/backend/tests/domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/domain/services/__init__.py b/backend/tests/domain/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/domain/services/test_calculador_pontuacao.py b/backend/tests/domain/services/test_calculador_pontuacao.py new file mode 100644 index 0000000..2700203 --- /dev/null +++ b/backend/tests/domain/services/test_calculador_pontuacao.py @@ -0,0 +1,599 @@ +import pytest +from datetime import datetime +from dateutil.relativedelta import relativedelta + +from src.domain.services.calculador_pontuacao import CalculadorPontuacao +from src.domain.entities.consultor import ( + Consultor, + CoordenacaoCapes, + Consultoria, + Inscricao, + AvaliacaoComissao, + Premiacao, + BolsaCNPQ, + Participacao, +) +from src.domain.value_objects.periodo import Periodo +from src.domain.value_objects.criterios_pontuacao import CRITERIOS + + +def criar_periodo(anos_atras: int, ativo: bool = False, duracao_anos: int = 0) -> Periodo: + inicio = datetime.now() - relativedelta(years=anos_atras) + if ativo: + fim = None + else: + fim = inicio + relativedelta(years=duracao_anos) if duracao_anos > 0 else datetime.now() + return Periodo(inicio=inicio, fim=fim) + + +class TestBlocoACoordenacaoCapes: + + def test_coordenacao_vazia_retorna_bloco_vazio(self): + resultado = CalculadorPontuacao.calcular_bloco_a([]) + assert resultado.bloco == "A" + assert resultado.total == 0 + assert len(resultado.atuacoes) == 0 + + def test_coordenador_area_base_200_pontos(self): + coord = CoordenacaoCapes( + codigo="CA", + tipo="Coordenador de Área", + area_avaliacao="CIÊNCIAS AMBIENTAIS", + periodo=criar_periodo(anos_atras=0, duracao_anos=0), + ) + resultado = CalculadorPontuacao.calcular_bloco_a([coord]) + assert resultado.total >= 200 + + def test_coordenador_area_ativo_recebe_bonus_atualidade(self): + coord = CoordenacaoCapes( + codigo="CA", + tipo="Coordenador de Área", + area_avaliacao="CIÊNCIAS AMBIENTAIS", + periodo=criar_periodo(anos_atras=1, ativo=True), + ) + resultado = CalculadorPontuacao.calcular_bloco_a([coord]) + atuacao = resultado.atuacoes[0] + assert atuacao.bonus >= 30 + + def test_coordenador_area_historico_sem_bonus_atualidade(self): + coord = CoordenacaoCapes( + codigo="CA", + tipo="Coordenador de Área", + area_avaliacao="MEDICINA I", + periodo=criar_periodo(anos_atras=5, duracao_anos=3), + ) + resultado = CalculadorPontuacao.calcular_bloco_a([coord]) + atuacao = resultado.atuacoes[0] + assert atuacao.codigo == "CA" + assert 30 not in [atuacao.bonus] or atuacao.bonus < 30 + + def test_coordenador_area_5_anos_pontos_tempo(self): + coord = CoordenacaoCapes( + codigo="CA", + tipo="Coordenador de Área", + area_avaliacao="CIÊNCIAS AMBIENTAIS", + periodo=criar_periodo(anos_atras=5, duracao_anos=5), + ) + resultado = CalculadorPontuacao.calcular_bloco_a([coord]) + atuacao = resultado.atuacoes[0] + assert atuacao.tempo == 50 + + def test_coordenador_area_teto_tempo_100_pontos(self): + coord = CoordenacaoCapes( + codigo="CA", + tipo="Coordenador de Área", + area_avaliacao="CIÊNCIAS AMBIENTAIS", + periodo=criar_periodo(anos_atras=15, duracao_anos=15), + ) + resultado = CalculadorPontuacao.calcular_bloco_a([coord]) + atuacao = resultado.atuacoes[0] + assert atuacao.tempo == 100 + + def test_coordenador_area_teto_maximo_450(self): + coord = CoordenacaoCapes( + codigo="CA", + tipo="Coordenador de Área", + area_avaliacao="CIÊNCIAS AMBIENTAIS", + periodo=criar_periodo(anos_atras=20, ativo=True), + ) + resultado = CalculadorPontuacao.calcular_bloco_a([coord]) + atuacao = resultado.atuacoes[0] + assert atuacao.total <= 450 + + def test_coordenador_adjunto_base_150_pontos(self): + coord = CoordenacaoCapes( + codigo="CAJ", + tipo="Coordenador Adjunto", + area_avaliacao="ENGENHARIA I", + periodo=criar_periodo(anos_atras=0, duracao_anos=0), + ) + resultado = CalculadorPontuacao.calcular_bloco_a([coord]) + assert resultado.total >= 150 + + def test_coordenador_adjunto_mp_base_120_pontos(self): + coord = CoordenacaoCapes( + codigo="CAJ_MP", + tipo="Coordenador Adjunto de Mestrado Profissionalizante", + area_avaliacao="ADMINISTRAÇÃO", + periodo=criar_periodo(anos_atras=0, duracao_anos=0), + ) + resultado = CalculadorPontuacao.calcular_bloco_a([coord]) + assert resultado.total >= 120 + + def test_camara_tematica_base_100_pontos(self): + coord = CoordenacaoCapes( + codigo="CAM", + tipo="Câmara Temática", + area_avaliacao="INTERDISCIPLINAR", + periodo=criar_periodo(anos_atras=0, duracao_anos=0), + ) + resultado = CalculadorPontuacao.calcular_bloco_a([coord]) + assert resultado.total >= 100 + + def test_multiplas_coordenacoes_mesmo_tipo_soma_tempo(self): + periodo1 = criar_periodo(anos_atras=10, duracao_anos=3) + periodo2 = criar_periodo(anos_atras=5, duracao_anos=3) + coords = [ + CoordenacaoCapes( + codigo="CA", tipo="Coordenador de Área", + area_avaliacao="ÁREA 1", periodo=periodo1 + ), + CoordenacaoCapes( + codigo="CA", tipo="Coordenador de Área", + area_avaliacao="ÁREA 2", periodo=periodo2 + ), + ] + resultado = CalculadorPontuacao.calcular_bloco_a(coords) + assert len(resultado.atuacoes) == 1 + atuacao = resultado.atuacoes[0] + assert atuacao.quantidade == 2 + assert atuacao.tempo == 60 + + def test_retorno_coordenacao_gera_bonus(self): + periodo1 = criar_periodo(anos_atras=10, duracao_anos=3) + periodo2 = criar_periodo(anos_atras=3, ativo=True) + coords = [ + CoordenacaoCapes( + codigo="CA", tipo="Coordenador de Área", + area_avaliacao="ÁREA 1", periodo=periodo1 + ), + CoordenacaoCapes( + codigo="CA", tipo="Coordenador de Área", + area_avaliacao="ÁREA 1", periodo=periodo2 + ), + ] + resultado = CalculadorPontuacao.calcular_bloco_a(coords) + atuacao = resultado.atuacoes[0] + assert atuacao.bonus >= 20 + + def test_tipos_diferentes_geram_atuacoes_separadas(self): + coords = [ + CoordenacaoCapes( + codigo="CA", tipo="Coordenador de Área", + area_avaliacao="ÁREA 1", periodo=criar_periodo(2, duracao_anos=2) + ), + CoordenacaoCapes( + codigo="CAJ", tipo="Coordenador Adjunto", + area_avaliacao="ÁREA 1", periodo=criar_periodo(2, duracao_anos=2) + ), + ] + resultado = CalculadorPontuacao.calcular_bloco_a(coords) + assert len(resultado.atuacoes) == 2 + + def test_codigo_com_hifen_normalizado(self): + coord = CoordenacaoCapes( + codigo="CAJ-MP", + tipo="Coordenador Adjunto de Mestrado Profissionalizante", + area_avaliacao="ADMINISTRAÇÃO", + periodo=criar_periodo(anos_atras=2, duracao_anos=2), + ) + resultado = CalculadorPontuacao.calcular_bloco_a([coord]) + assert len(resultado.atuacoes) == 1 + assert resultado.atuacoes[0].codigo == "CAJ_MP" + + +class TestBlocoBConsultoria: + + def test_consultoria_vazia_retorna_bloco_vazio(self): + resultado = CalculadorPontuacao.calcular_bloco_b(None) + assert resultado.bloco == "B" + assert resultado.total == 0 + + def test_consultor_ativo_base_150_pontos(self): + consultoria = Consultoria( + codigo="CONS_ATIVO", + situacao="Atividade Contínua", + periodo=criar_periodo(anos_atras=1, ativo=True), + anos_consecutivos=1, + retornos=0, + ) + resultado = CalculadorPontuacao.calcular_bloco_b(consultoria) + assert resultado.total >= 150 + + def test_consultor_historico_base_100_pontos(self): + consultoria = Consultoria( + codigo="CONS_HIST", + situacao="Inativo", + periodo=criar_periodo(anos_atras=5, duracao_anos=3), + anos_consecutivos=3, + retornos=0, + ) + resultado = CalculadorPontuacao.calcular_bloco_b(consultoria) + assert resultado.total >= 100 + + def test_consultor_falecido_base_100_pontos(self): + consultoria = Consultoria( + codigo="CONS_FALECIDO", + situacao="Falecido", + periodo=criar_periodo(anos_atras=10, duracao_anos=8), + anos_consecutivos=8, + retornos=0, + ) + resultado = CalculadorPontuacao.calcular_bloco_b(consultoria) + assert resultado.total >= 100 + + def test_consultoria_5_anos_pontos_tempo(self): + consultoria = Consultoria( + codigo="CONS_ATIVO", + situacao="Atividade Contínua", + periodo=criar_periodo(anos_atras=5, ativo=True), + periodos=[criar_periodo(anos_atras=5, ativo=True)], + anos_consecutivos=5, + retornos=0, + ) + resultado = CalculadorPontuacao.calcular_bloco_b(consultoria) + atuacao = resultado.atuacoes[0] + assert atuacao.tempo == 25 + + def test_consultoria_teto_tempo_50_pontos(self): + consultoria = Consultoria( + codigo="CONS_ATIVO", + situacao="Atividade Contínua", + periodo=criar_periodo(anos_atras=15, ativo=True), + periodos=[criar_periodo(anos_atras=15, ativo=True)], + anos_consecutivos=15, + retornos=0, + ) + resultado = CalculadorPontuacao.calcular_bloco_b(consultoria) + atuacao = resultado.atuacoes[0] + assert atuacao.tempo == 50 + + def test_consultor_ativo_bonus_atualidade_20(self): + consultoria = Consultoria( + codigo="CONS_ATIVO", + situacao="Atividade Contínua", + periodo=criar_periodo(anos_atras=1, ativo=True), + anos_consecutivos=1, + retornos=0, + ) + resultado = CalculadorPontuacao.calcular_bloco_b(consultoria) + atuacao = resultado.atuacoes[0] + assert atuacao.bonus >= 20 + + def test_consultor_8_anos_bonus_continuidade(self): + consultoria = Consultoria( + codigo="CONS_ATIVO", + situacao="Atividade Contínua", + periodo=criar_periodo(anos_atras=8, ativo=True), + anos_consecutivos=8, + retornos=0, + ) + resultado = CalculadorPontuacao.calcular_bloco_b(consultoria) + atuacao = resultado.atuacoes[0] + assert atuacao.bonus >= 40 + + def test_consultor_com_retorno_bonus_15(self): + consultoria = Consultoria( + codigo="CONS_ATIVO", + situacao="Atividade Contínua", + periodo=criar_periodo(anos_atras=2, ativo=True), + periodos=[ + criar_periodo(anos_atras=8, duracao_anos=3), + criar_periodo(anos_atras=2, ativo=True), + ], + anos_consecutivos=2, + retornos=1, + ) + resultado = CalculadorPontuacao.calcular_bloco_b(consultoria) + atuacao = resultado.atuacoes[0] + assert atuacao.bonus >= 35 + + def test_consultoria_teto_maximo_230(self): + consultoria = Consultoria( + codigo="CONS_ATIVO", + situacao="Atividade Contínua", + periodo=criar_periodo(anos_atras=20, ativo=True), + periodos=[criar_periodo(anos_atras=20, ativo=True)], + anos_consecutivos=20, + retornos=1, + ) + resultado = CalculadorPontuacao.calcular_bloco_b(consultoria) + atuacao = resultado.atuacoes[0] + assert atuacao.total <= 230 + + +class TestBlocoCPremiacoesAvaliacoes: + + def test_bloco_c_vazio(self): + resultado = CalculadorPontuacao.calcular_bloco_c([], [], [], [], []) + assert resultado.bloco == "C" + assert resultado.total == 0 + + def test_inscricao_autor_base_10_pontos(self): + inscricao = Inscricao( + codigo="INSC_AUTOR", tipo="Autor", + premio="PCT", ano=2024 + ) + resultado = CalculadorPontuacao.calcular_bloco_c([inscricao], [], [], [], []) + atuacao = next((a for a in resultado.atuacoes if a.codigo == "INSC_AUTOR"), None) + assert atuacao is not None + assert atuacao.base >= 10 + + def test_inscricao_institucional_base_20_pontos(self): + inscricao = Inscricao( + codigo="INSC_INST_AUTOR", tipo="Institucional", + premio="PCT", ano=2024 + ) + resultado = CalculadorPontuacao.calcular_bloco_c([inscricao], [], [], [], []) + atuacao = next((a for a in resultado.atuacoes if a.codigo == "INSC_INST_AUTOR"), None) + assert atuacao is not None + assert atuacao.base >= 20 + + def test_avaliacao_comissao_premio_base_30(self): + avaliacao = AvaliacaoComissao( + codigo="AVAL_COMIS_PREMIO", tipo="Membro de Comissão", + premio="PCT", ano=2024 + ) + resultado = CalculadorPontuacao.calcular_bloco_c([], [avaliacao], [], [], []) + atuacao = next((a for a in resultado.atuacoes if a.codigo == "AVAL_COMIS_PREMIO"), None) + assert atuacao is not None + assert atuacao.base >= 30 + + def test_avaliacao_comissao_gp_base_40(self): + avaliacao = AvaliacaoComissao( + codigo="AVAL_COMIS_GP", tipo="Membro de Comissão", + premio="Grande Prêmio", ano=2024 + ) + resultado = CalculadorPontuacao.calcular_bloco_c([], [avaliacao], [], [], []) + atuacao = next((a for a in resultado.atuacoes if a.codigo == "AVAL_COMIS_GP"), None) + assert atuacao is not None + assert atuacao.base >= 40 + + def test_coord_comissao_premio_base_40(self): + avaliacao = AvaliacaoComissao( + codigo="COORD_COMIS_PREMIO", tipo="Coordenador", + premio="PCT", ano=2024 + ) + resultado = CalculadorPontuacao.calcular_bloco_c([], [avaliacao], [], [], []) + atuacao = next((a for a in resultado.atuacoes if a.codigo == "COORD_COMIS_PREMIO"), None) + assert atuacao is not None + assert atuacao.base >= 40 + + def test_coord_comissao_gp_base_50(self): + avaliacao = AvaliacaoComissao( + codigo="COORD_COMIS_GP", tipo="Coordenador", + premio="Grande Prêmio", ano=2024 + ) + resultado = CalculadorPontuacao.calcular_bloco_c([], [avaliacao], [], [], []) + atuacao = next((a for a in resultado.atuacoes if a.codigo == "COORD_COMIS_GP"), None) + assert atuacao is not None + assert atuacao.base >= 50 + + def test_premiacao_gp_autor_base_100(self): + premiacao = Premiacao( + codigo="PREMIACAO_GP_AUTOR", tipo="Grande Prêmio", + nome_premio="Grande Prêmio CAPES", ano=2024 + ) + resultado = CalculadorPontuacao.calcular_bloco_c([], [], [premiacao], [], []) + atuacao = next((a for a in resultado.atuacoes if a.codigo == "PREMIACAO_GP_AUTOR"), None) + assert atuacao is not None + assert atuacao.base >= 100 + + def test_premiacao_autor_base_50(self): + premiacao = Premiacao( + codigo="PREMIACAO_AUTOR", tipo="Prêmio", + nome_premio="Prêmio CAPES de Tese", ano=2024 + ) + resultado = CalculadorPontuacao.calcular_bloco_c([], [], [premiacao], [], []) + atuacao = next((a for a in resultado.atuacoes if a.codigo == "PREMIACAO_AUTOR"), None) + assert atuacao is not None + assert atuacao.base >= 50 + + def test_mencao_autor_base_30(self): + premiacao = Premiacao( + codigo="MENCAO_AUTOR", tipo="Menção Honrosa", + nome_premio="Prêmio CAPES de Tese", ano=2024 + ) + resultado = CalculadorPontuacao.calcular_bloco_c([], [], [premiacao], [], []) + atuacao = next((a for a in resultado.atuacoes if a.codigo == "MENCAO_AUTOR"), None) + assert atuacao is not None + assert atuacao.base >= 30 + + def test_avaliacao_recorrente_bonus_anual(self): + avaliacoes = [ + AvaliacaoComissao( + codigo="AVAL_COMIS_PREMIO", tipo="Membro", + premio="PCT", ano=2022 + ), + AvaliacaoComissao( + codigo="AVAL_COMIS_PREMIO", tipo="Membro", + premio="PCT", ano=2023 + ), + AvaliacaoComissao( + codigo="AVAL_COMIS_PREMIO", tipo="Membro", + premio="PCT", ano=2024 + ), + ] + resultado = CalculadorPontuacao.calcular_bloco_c([], avaliacoes, [], [], []) + atuacao = next((a for a in resultado.atuacoes if a.codigo == "AVAL_COMIS_PREMIO"), None) + assert atuacao is not None + assert atuacao.bonus > 0 + assert atuacao.quantidade == 3 + + def test_inscricoes_multiplas_acumulam(self): + inscricoes = [ + Inscricao(codigo="INSC_AUTOR", tipo="Autor", premio="PCT", ano=2022), + Inscricao(codigo="INSC_AUTOR", tipo="Autor", premio="PCT", ano=2023), + ] + resultado = CalculadorPontuacao.calcular_bloco_c(inscricoes, [], [], [], []) + atuacao = next((a for a in resultado.atuacoes if a.codigo == "INSC_AUTOR"), None) + assert atuacao is not None + assert atuacao.quantidade == 2 + assert atuacao.base == 20 + + def test_teto_inscricao_autor_20_pontos(self): + inscricoes = [ + Inscricao(codigo="INSC_AUTOR", tipo="Autor", premio="PCT", ano=i) + for i in range(2015, 2025) + ] + resultado = CalculadorPontuacao.calcular_bloco_c(inscricoes, [], [], [], []) + atuacao = next((a for a in resultado.atuacoes if a.codigo == "INSC_AUTOR"), None) + assert atuacao is not None + assert atuacao.total <= 20 + + +class TestBlocoDParticipacoes: + + def test_bloco_d_vazio(self): + resultado = CalculadorPontuacao.calcular_bloco_d([], []) + assert resultado.bloco == "D" + assert resultado.total == 0 + + def test_bolsa_cnpq_base_30(self): + bolsa = BolsaCNPQ(codigo="BOL_BPQ_NIVEL", nivel="1A") + resultado = CalculadorPontuacao.calcular_bloco_d([bolsa], []) + atuacao = next((a for a in resultado.atuacoes if a.codigo == "BOL_BPQ_NIVEL"), None) + assert atuacao is not None + assert atuacao.base >= 30 + + def test_evento_base_1_ponto(self): + evento = Participacao(codigo="EVENTO", tipo="Evento", ano=2024) + resultado = CalculadorPontuacao.calcular_bloco_d([], [evento]) + atuacao = next((a for a in resultado.atuacoes if a.codigo == "EVENTO"), None) + assert atuacao is not None + assert atuacao.base >= 1 + + def test_projeto_base_10_pontos(self): + projeto = Participacao(codigo="PROJ", tipo="Projeto", ano=2024) + resultado = CalculadorPontuacao.calcular_bloco_d([], [projeto]) + atuacao = next((a for a in resultado.atuacoes if a.codigo == "PROJ"), None) + assert atuacao is not None + assert atuacao.base >= 10 + + def test_eventos_multiplos_bonus_recorrencia(self): + eventos = [ + Participacao(codigo="EVENTO", tipo="Evento", ano=i) + for i in range(2020, 2025) + ] + resultado = CalculadorPontuacao.calcular_bloco_d([], eventos) + atuacao = next((a for a in resultado.atuacoes if a.codigo == "EVENTO"), None) + assert atuacao is not None + assert atuacao.quantidade == 5 + assert atuacao.bonus > 0 + + def test_evento_teto_5_pontos(self): + eventos = [ + Participacao(codigo="EVENTO", tipo="Evento", ano=i) + for i in range(2000, 2025) + ] + resultado = CalculadorPontuacao.calcular_bloco_d([], eventos) + atuacao = next((a for a in resultado.atuacoes if a.codigo == "EVENTO"), None) + assert atuacao is not None + assert atuacao.total <= 5 + + def test_projeto_teto_30_pontos(self): + projetos = [ + Participacao(codigo="PROJ", tipo="Projeto", ano=i) + for i in range(2000, 2025) + ] + resultado = CalculadorPontuacao.calcular_bloco_d([], projetos) + atuacao = next((a for a in resultado.atuacoes if a.codigo == "PROJ"), None) + assert atuacao is not None + assert atuacao.total <= 30 + + +class TestBlocoECoordPPG: + + def test_bloco_e_sem_coordenador(self): + resultado = CalculadorPontuacao.calcular_bloco_e(False) + assert resultado.bloco == "E" + assert resultado.total == 0 + + def test_bloco_e_com_coordenador(self): + resultado = CalculadorPontuacao.calcular_bloco_e(True) + assert resultado.bloco == "E" + assert len(resultado.atuacoes) == 1 + assert resultado.atuacoes[0].codigo == "PPG_COORD" + + +class TestPontuacaoCompleta: + + def test_consultor_vazio_pontuacao_zero(self, consultor_vazio): + resultado = CalculadorPontuacao.calcular_pontuacao_completa(consultor_vazio) + assert resultado.total == 0 + + def test_consultor_com_coordenacao(self, coordenacao_ca_ativa): + consultor = Consultor( + id_pessoa=1, + nome="Coordenador", + coordenacoes_capes=[coordenacao_ca_ativa], + ) + resultado = CalculadorPontuacao.calcular_pontuacao_completa(consultor) + assert resultado.bloco_a.total > 0 + assert resultado.bloco_b.total == 0 + + def test_consultor_completo_todos_blocos(self, consultor_completo): + resultado = CalculadorPontuacao.calcular_pontuacao_completa(consultor_completo) + assert resultado.bloco_a.total > 0 + assert resultado.bloco_b.total > 0 + assert resultado.bloco_c.total > 0 + assert resultado.bloco_d.total > 0 + + def test_pontuacao_total_soma_blocos(self, consultor_completo): + resultado = CalculadorPontuacao.calcular_pontuacao_completa(consultor_completo) + soma_esperada = ( + resultado.bloco_a.total + + resultado.bloco_b.total + + resultado.bloco_c.total + + resultado.bloco_d.total + + resultado.bloco_e.total + ) + assert resultado.total == soma_esperada + + def test_detalhamento_retorna_dict(self, consultor_completo): + resultado = CalculadorPontuacao.calcular_pontuacao_completa(consultor_completo) + detalhes = resultado.detalhamento + assert isinstance(detalhes, dict) + assert "bloco_a" in detalhes + assert "bloco_b" in detalhes + assert "bloco_c" in detalhes + assert "bloco_d" in detalhes + assert "bloco_e" in detalhes + assert "pontuacao_total" in detalhes + + +class TestCriteriosConfigurados: + + def test_criterio_ca_existe(self): + assert "CA" in CRITERIOS + criterio = CRITERIOS["CA"] + assert criterio.base == 200 + assert criterio.teto == 450 + + def test_criterio_caj_existe(self): + assert "CAJ" in CRITERIOS + criterio = CRITERIOS["CAJ"] + assert criterio.base == 150 + assert criterio.teto == 370 + + def test_criterio_cons_ativo_existe(self): + assert "CONS_ATIVO" in CRITERIOS + criterio = CRITERIOS["CONS_ATIVO"] + assert criterio.base == 150 + assert criterio.teto == 230 + + def test_todos_criterios_tem_codigo_valido(self): + for codigo, criterio in CRITERIOS.items(): + assert criterio.codigo == codigo + assert criterio.base >= 0 + assert criterio.teto >= 0 diff --git a/backend/tests/domain/value_objects/__init__.py b/backend/tests/domain/value_objects/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/domain/value_objects/test_periodo.py b/backend/tests/domain/value_objects/test_periodo.py new file mode 100644 index 0000000..ac22e7d --- /dev/null +++ b/backend/tests/domain/value_objects/test_periodo.py @@ -0,0 +1,169 @@ +import pytest +from datetime import datetime +from dateutil.relativedelta import relativedelta + +from src.domain.value_objects.periodo import ( + Periodo, + mesclar_periodos, + anos_completos_periodos, +) + + +class TestPeriodo: + + def test_periodo_ativo_sem_fim(self): + inicio = datetime(2020, 1, 1) + periodo = Periodo(inicio=inicio, fim=None) + assert periodo.ativo is True + + def test_periodo_inativo_com_fim(self): + inicio = datetime(2020, 1, 1) + fim = datetime(2023, 1, 1) + periodo = Periodo(inicio=inicio, fim=fim) + assert periodo.ativo is False + + def test_anos_decorridos_3_anos(self): + inicio = datetime.now() - relativedelta(years=3) + fim = datetime.now() + periodo = Periodo(inicio=inicio, fim=fim) + assert periodo.anos_decorridos >= 2.9 + assert periodo.anos_decorridos <= 3.1 + + def test_anos_decorridos_periodo_ativo(self): + inicio = datetime.now() - relativedelta(years=5) + periodo = Periodo(inicio=inicio, fim=None) + assert periodo.anos_decorridos >= 4.9 + assert periodo.anos_decorridos <= 5.1 + + def test_anos_completos_retorna_inteiro(self): + inicio = datetime(2020, 1, 1) + fim = datetime(2023, 6, 15) + periodo = Periodo(inicio=inicio, fim=fim) + assert periodo.anos_completos() == 3 + + def test_anos_completos_menos_de_um_ano(self): + inicio = datetime.now() - relativedelta(months=6) + fim = datetime.now() + periodo = Periodo(inicio=inicio, fim=fim) + assert periodo.anos_completos() == 0 + + def test_anos_completos_com_data_referencia(self): + inicio = datetime(2020, 1, 1) + periodo = Periodo(inicio=inicio, fim=None) + data_ref = datetime(2025, 1, 1) + assert periodo.anos_completos(data_ref) == 5 + + def test_fim_anterior_inicio_corrige_para_none(self): + inicio = datetime(2023, 1, 1) + fim = datetime(2020, 1, 1) + periodo = Periodo(inicio=inicio, fim=fim) + assert periodo.fim is None + assert periodo.ativo is True + + +class TestMesclarPeriodos: + + def test_lista_vazia_retorna_vazia(self): + resultado = mesclar_periodos([]) + assert resultado == [] + + def test_um_periodo_retorna_mesmo(self): + periodo = Periodo(datetime(2020, 1, 1), datetime(2023, 1, 1)) + resultado = mesclar_periodos([periodo]) + assert len(resultado) == 1 + assert resultado[0].inicio == periodo.inicio + + def test_periodos_consecutivos_mesclados(self): + p1 = Periodo(datetime(2020, 1, 1), datetime(2022, 1, 1)) + p2 = Periodo(datetime(2021, 6, 1), datetime(2024, 1, 1)) + resultado = mesclar_periodos([p1, p2]) + assert len(resultado) == 1 + assert resultado[0].inicio == datetime(2020, 1, 1) + assert resultado[0].fim == datetime(2024, 1, 1) + + def test_periodos_separados_nao_mesclados(self): + p1 = Periodo(datetime(2015, 1, 1), datetime(2017, 1, 1)) + p2 = Periodo(datetime(2020, 1, 1), datetime(2023, 1, 1)) + resultado = mesclar_periodos([p1, p2]) + assert len(resultado) == 2 + + def test_periodo_ativo_preservado(self): + p1 = Periodo(datetime(2020, 1, 1), datetime(2022, 1, 1)) + p2 = Periodo(datetime(2021, 1, 1), None) + resultado = mesclar_periodos([p1, p2]) + assert len(resultado) == 1 + assert resultado[0].ativo is True + + def test_tres_periodos_mesclados(self): + p1 = Periodo(datetime(2018, 1, 1), datetime(2020, 1, 1)) + p2 = Periodo(datetime(2019, 1, 1), datetime(2021, 1, 1)) + p3 = Periodo(datetime(2020, 6, 1), datetime(2023, 1, 1)) + resultado = mesclar_periodos([p1, p2, p3]) + assert len(resultado) == 1 + assert resultado[0].inicio == datetime(2018, 1, 1) + assert resultado[0].fim == datetime(2023, 1, 1) + + def test_ordenacao_automatica(self): + p1 = Periodo(datetime(2022, 1, 1), datetime(2024, 1, 1)) + p2 = Periodo(datetime(2018, 1, 1), datetime(2020, 1, 1)) + resultado = mesclar_periodos([p1, p2]) + assert resultado[0].inicio == datetime(2018, 1, 1) + + +class TestAnosCompletosPeriodos: + + def test_lista_vazia_retorna_zero(self): + resultado = anos_completos_periodos([]) + assert resultado == 0 + + def test_um_periodo_3_anos(self): + periodo = Periodo(datetime(2020, 1, 1), datetime(2023, 1, 1)) + resultado = anos_completos_periodos([periodo]) + assert resultado == 3 + + def test_dois_periodos_soma(self): + p1 = Periodo(datetime(2015, 1, 1), datetime(2017, 1, 1)) + p2 = Periodo(datetime(2020, 1, 1), datetime(2023, 1, 1)) + resultado = anos_completos_periodos([p1, p2]) + assert resultado == 5 + + def test_com_data_referencia(self): + p1 = Periodo(datetime(2020, 1, 1), None) + p2 = Periodo(datetime(2015, 1, 1), datetime(2017, 1, 1)) + data_ref = datetime(2025, 1, 1) + resultado = anos_completos_periodos([p1, p2], data_ref) + assert resultado == 7 + + +class TestCasosEspeciais: + + def test_periodo_muito_curto(self): + inicio = datetime.now() - relativedelta(days=30) + fim = datetime.now() + periodo = Periodo(inicio=inicio, fim=fim) + assert periodo.anos_completos() == 0 + + def test_periodo_exatamente_um_ano(self): + inicio = datetime(2023, 1, 1) + fim = datetime(2024, 1, 1) + periodo = Periodo(inicio=inicio, fim=fim) + assert periodo.anos_completos() == 1 + + def test_periodo_quase_um_ano(self): + inicio = datetime(2023, 1, 1) + fim = datetime(2023, 12, 31) + periodo = Periodo(inicio=inicio, fim=fim) + assert periodo.anos_completos() == 0 + + def test_mesclagem_periodos_sobrepostos_complexos(self): + periodos = [ + Periodo(datetime(2010, 1, 1), datetime(2012, 1, 1)), + Periodo(datetime(2011, 6, 1), datetime(2014, 1, 1)), + Periodo(datetime(2013, 1, 1), datetime(2015, 1, 1)), + Periodo(datetime(2018, 1, 1), datetime(2020, 1, 1)), + Periodo(datetime(2019, 6, 1), None), + ] + resultado = mesclar_periodos(periodos) + assert len(resultado) == 2 + assert resultado[0].fim == datetime(2015, 1, 1) + assert resultado[1].ativo is True diff --git a/backend/tests/infrastructure/__init__.py b/backend/tests/infrastructure/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/infrastructure/elasticsearch/test_client.py b/backend/tests/infrastructure/elasticsearch/test_client.py new file mode 100644 index 0000000..6432d92 --- /dev/null +++ b/backend/tests/infrastructure/elasticsearch/test_client.py @@ -0,0 +1,508 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock, patch +from httpx import Response + +from src.infrastructure.elasticsearch.client import ElasticsearchClient + + +@pytest.fixture +def es_client(): + return ElasticsearchClient( + url="http://localhost:9200", + index="atuacapes_test", + user="test_user", + password="test_pass" + ) + + +@pytest.fixture +def mock_httpx_response(): + def _create_response(json_data, status_code=200): + response = MagicMock(spec=Response) + response.json.return_value = json_data + response.status_code = status_code + response.raise_for_status = MagicMock() + return response + return _create_response + + +class TestElasticsearchClientConnect: + @pytest.mark.asyncio + async def test_connect_cria_cliente(self, es_client): + await es_client.connect() + assert es_client._client is not None + await es_client.close() + + @pytest.mark.asyncio + async def test_connect_com_auth(self, es_client): + await es_client.connect() + assert es_client._client is not None + await es_client.close() + + @pytest.mark.asyncio + async def test_close_fecha_cliente(self, es_client): + await es_client.connect() + await es_client.close() + + @pytest.mark.asyncio + async def test_client_property_sem_conexao(self, es_client): + with pytest.raises(RuntimeError, match="não conectado"): + _ = es_client.client + + +class TestBuscarPorId: + @pytest.mark.asyncio + async def test_buscar_por_id_encontrado(self, es_client, mock_httpx_response): + es_response = { + "hits": { + "total": {"value": 1}, + "hits": [{ + "_source": { + "id": 12345, + "dadosPessoais": {"nome": "MARIA SILVA"}, + "atuacoes": [] + } + }] + } + } + + with patch.object(es_client, '_client') as mock_client: + mock_client.is_closed = False + mock_client.post = AsyncMock(return_value=mock_httpx_response(es_response)) + + result = await es_client.buscar_por_id(12345) + + assert result is not None + assert result["id"] == 12345 + assert result["dadosPessoais"]["nome"] == "MARIA SILVA" + + @pytest.mark.asyncio + async def test_buscar_por_id_nao_encontrado(self, es_client, mock_httpx_response): + es_response = { + "hits": { + "total": {"value": 0}, + "hits": [] + } + } + + with patch.object(es_client, '_client') as mock_client: + mock_client.is_closed = False + mock_client.post = AsyncMock(return_value=mock_httpx_response(es_response)) + + result = await es_client.buscar_por_id(99999) + + assert result is None + + @pytest.mark.asyncio + async def test_buscar_por_id_erro(self, es_client): + with patch.object(es_client, '_client') as mock_client: + mock_client.is_closed = False + mock_client.post = AsyncMock(side_effect=Exception("Connection failed")) + + with pytest.raises(RuntimeError, match="Erro ao buscar consultor"): + await es_client.buscar_por_id(12345) + + +class TestBuscarPorIds: + @pytest.mark.asyncio + async def test_buscar_por_ids_multiplos(self, es_client, mock_httpx_response): + es_response = { + "hits": { + "hits": [ + {"_source": {"id": 1, "dadosPessoais": {"nome": "PESSOA 1"}}}, + {"_source": {"id": 2, "dadosPessoais": {"nome": "PESSOA 2"}}}, + {"_source": {"id": 3, "dadosPessoais": {"nome": "PESSOA 3"}}} + ] + } + } + + with patch.object(es_client, '_client') as mock_client: + mock_client.is_closed = False + mock_client.post = AsyncMock(return_value=mock_httpx_response(es_response)) + + result = await es_client.buscar_por_ids([1, 2, 3]) + + assert len(result) == 3 + assert result[0]["id"] == 1 + assert result[2]["dadosPessoais"]["nome"] == "PESSOA 3" + + @pytest.mark.asyncio + async def test_buscar_por_ids_vazio(self, es_client): + result = await es_client.buscar_por_ids([]) + assert result == [] + + @pytest.mark.asyncio + async def test_buscar_por_ids_com_source_fields(self, es_client, mock_httpx_response): + es_response = {"hits": {"hits": [{"_source": {"id": 1}}]}} + + with patch.object(es_client, '_client') as mock_client: + mock_client.is_closed = False + mock_client.post = AsyncMock(return_value=mock_httpx_response(es_response)) + + await es_client.buscar_por_ids([1], source_fields=["id", "dadosPessoais.nome"]) + + call_args = mock_client.post.call_args + query = call_args[1]["json"] + assert "_source" in query + assert "id" in query["_source"] + + +class TestBuscarDocumentoCompleto: + @pytest.mark.asyncio + async def test_buscar_documento_completo(self, es_client, mock_httpx_response): + es_response = { + "hits": { + "hits": [{ + "_index": "atuacapes_test", + "_id": "abc123", + "_score": 1.0, + "_source": {"id": 12345, "dadosPessoais": {"nome": "TESTE"}} + }] + } + } + + with patch.object(es_client, '_client') as mock_client: + mock_client.is_closed = False + mock_client.post = AsyncMock(return_value=mock_httpx_response(es_response)) + + result = await es_client.buscar_documento_completo(12345) + + assert result is not None + assert result["_index"] == "atuacapes_test" + assert result["_id"] == "abc123" + assert result["_source"]["id"] == 12345 + + +class TestBuscarComAtuacoes: + @pytest.mark.asyncio + async def test_buscar_com_atuacoes(self, es_client, mock_httpx_response): + es_response = { + "hits": { + "hits": [ + {"_source": {"id": 1, "atuacoes": [{"tipo": "Consultor"}]}}, + {"_source": {"id": 2, "atuacoes": [{"tipo": "Coordenação de Área de Avaliação"}]}} + ] + } + } + + with patch.object(es_client, '_client') as mock_client: + mock_client.is_closed = False + mock_client.post = AsyncMock(return_value=mock_httpx_response(es_response)) + + result = await es_client.buscar_com_atuacoes(size=100) + + assert len(result) == 2 + assert result[0]["atuacoes"][0]["tipo"] == "Consultor" + + @pytest.mark.asyncio + async def test_buscar_com_atuacoes_paginacao(self, es_client, mock_httpx_response): + es_response = {"hits": {"hits": []}} + + with patch.object(es_client, '_client') as mock_client: + mock_client.is_closed = False + mock_client.post = AsyncMock(return_value=mock_httpx_response(es_response)) + + await es_client.buscar_com_atuacoes(size=50, from_=100) + + call_args = mock_client.post.call_args + query = call_args[1]["json"] + assert query["size"] == 50 + assert query["from"] == 100 + + +class TestContarComAtuacoes: + @pytest.mark.asyncio + async def test_contar_com_atuacoes(self, es_client, mock_httpx_response): + es_response = {"count": 85432} + + with patch.object(es_client, '_client') as mock_client: + mock_client.is_closed = False + mock_client.post = AsyncMock(return_value=mock_httpx_response(es_response)) + + result = await es_client.contar_com_atuacoes() + + assert result == 85432 + + @pytest.mark.asyncio + async def test_contar_com_atuacoes_erro(self, es_client): + with patch.object(es_client, '_client') as mock_client: + mock_client.is_closed = False + mock_client.post = AsyncMock(side_effect=Exception("Timeout")) + + with pytest.raises(RuntimeError, match="Erro ao contar consultores"): + await es_client.contar_com_atuacoes() + + +class TestBuscarCandidatosRanking: + @pytest.mark.asyncio + async def test_buscar_candidatos_ranking(self, es_client, mock_httpx_response): + es_response = { + "hits": { + "hits": [ + {"_score": 15.5, "_source": {"id": 1, "dadosPessoais": {"nome": "COORD A"}}}, + {"_score": 10.2, "_source": {"id": 2, "dadosPessoais": {"nome": "CONSULTOR B"}}} + ] + } + } + + with patch.object(es_client, '_client') as mock_client: + mock_client.is_closed = False + mock_client.post = AsyncMock(return_value=mock_httpx_response(es_response)) + + result = await es_client.buscar_candidatos_ranking(size=100) + + assert len(result) == 2 + assert result[0]["_score_es"] == 15.5 + assert result[1]["_score_es"] == 10.2 + + +class TestScrollAPI: + @pytest.mark.asyncio + async def test_iniciar_scroll(self, es_client, mock_httpx_response): + es_response = { + "_scroll_id": "scroll_123abc", + "hits": { + "total": {"value": 5000}, + "hits": [ + {"_source": {"id": 1}}, + {"_source": {"id": 2}}, + {"_source": {"id": 3}} + ] + } + } + + with patch.object(es_client, '_client') as mock_client: + mock_client.is_closed = False + mock_client.post = AsyncMock(return_value=mock_httpx_response(es_response)) + + result = await es_client.iniciar_scroll(size=1000) + + assert result["scroll_id"] == "scroll_123abc" + assert result["total"] == 5000 + assert len(result["hits"]) == 3 + + @pytest.mark.asyncio + async def test_continuar_scroll(self, es_client, mock_httpx_response): + es_response = { + "_scroll_id": "scroll_456def", + "hits": { + "hits": [ + {"_source": {"id": 4}}, + {"_source": {"id": 5}} + ] + } + } + + with patch.object(es_client, '_client') as mock_client: + mock_client.is_closed = False + mock_client.post = AsyncMock(return_value=mock_httpx_response(es_response)) + + result = await es_client.continuar_scroll("scroll_123abc") + + assert result["scroll_id"] == "scroll_456def" + assert len(result["hits"]) == 2 + + @pytest.mark.asyncio + async def test_limpar_scroll(self, es_client, mock_httpx_response): + with patch.object(es_client, '_client') as mock_client: + mock_client.is_closed = False + mock_client.delete = AsyncMock(return_value=mock_httpx_response({"succeeded": True})) + + await es_client.limpar_scroll("scroll_123abc") + + mock_client.delete.assert_called_once() + + @pytest.mark.asyncio + async def test_limpar_scroll_ignora_erros(self, es_client): + with patch.object(es_client, '_client') as mock_client: + mock_client.is_closed = False + mock_client.delete = AsyncMock(side_effect=Exception("Scroll already expired")) + + await es_client.limpar_scroll("scroll_expired") + + +class TestBuscarTodosConsultores: + @pytest.mark.asyncio + async def test_buscar_todos_consultores_callback(self, es_client, mock_httpx_response): + batches_recebidos = [] + progress_recebido = [] + + async def callback(docs, progress): + batches_recebidos.append(docs) + progress_recebido.append(progress.copy()) + + scroll_response_1 = { + "_scroll_id": "scroll_1", + "hits": { + "total": {"value": 6}, + "hits": [ + {"_source": {"id": 1}}, + {"_source": {"id": 2}}, + {"_source": {"id": 3}} + ] + } + } + + scroll_response_2 = { + "_scroll_id": "scroll_2", + "hits": { + "hits": [ + {"_source": {"id": 4}}, + {"_source": {"id": 5}}, + {"_source": {"id": 6}} + ] + } + } + + scroll_response_3 = { + "_scroll_id": "scroll_3", + "hits": {"hits": []} + } + + with patch.object(es_client, '_client') as mock_client: + mock_client.is_closed = False + mock_client.post = AsyncMock(side_effect=[ + mock_httpx_response(scroll_response_1), + mock_httpx_response(scroll_response_2), + mock_httpx_response(scroll_response_3) + ]) + mock_client.delete = AsyncMock(return_value=mock_httpx_response({})) + + result = await es_client.buscar_todos_consultores(callback, batch_size=3) + + assert result["total"] == 6 + assert result["processados"] == 6 + assert len(batches_recebidos) == 2 + assert progress_recebido[0]["percentual"] == 50 + assert progress_recebido[1]["percentual"] == 100 + + @pytest.mark.asyncio + async def test_buscar_todos_limpa_scroll_ao_final(self, es_client, mock_httpx_response): + async def callback(docs, progress): + pass + + scroll_response = { + "_scroll_id": "scroll_cleanup", + "hits": {"total": {"value": 0}, "hits": []} + } + + with patch.object(es_client, '_client') as mock_client: + mock_client.is_closed = False + mock_client.post = AsyncMock(return_value=mock_httpx_response(scroll_response)) + mock_client.delete = AsyncMock(return_value=mock_httpx_response({})) + + await es_client.buscar_todos_consultores(callback) + + mock_client.delete.assert_called_once() + + +class TestSugerirConsultores: + @pytest.mark.asyncio + async def test_sugerir_consultores_por_tema(self, es_client, mock_httpx_response): + es_response = { + "hits": { + "hits": [ + {"_score": 25.5, "_source": {"id": 1, "dadosPessoais": {"nome": "ESPECIALISTA IA"}}}, + {"_score": 20.1, "_source": {"id": 2, "dadosPessoais": {"nome": "PESQUISADOR ML"}}} + ] + } + } + + with patch.object(es_client, '_client') as mock_client: + mock_client.is_closed = False + mock_client.post = AsyncMock(return_value=mock_httpx_response(es_response)) + + result = await es_client.sugerir_consultores(tema="inteligência artificial") + + assert len(result) == 2 + assert result[0]["_score_match"] == 25.5 + + @pytest.mark.asyncio + async def test_sugerir_consultores_filtro_area(self, es_client, mock_httpx_response): + es_response = {"hits": {"hits": []}} + + with patch.object(es_client, '_client') as mock_client: + mock_client.is_closed = False + mock_client.post = AsyncMock(return_value=mock_httpx_response(es_response)) + + await es_client.sugerir_consultores( + tema="machine learning", + area_avaliacao="CIÊNCIA DA COMPUTAÇÃO" + ) + + call_args = mock_client.post.call_args + query = call_args[1]["json"] + assert len(query["query"]["bool"]["must"]) >= 2 + + @pytest.mark.asyncio + async def test_sugerir_consultores_apenas_ativos(self, es_client, mock_httpx_response): + es_response = {"hits": {"hits": []}} + + with patch.object(es_client, '_client') as mock_client: + mock_client.is_closed = False + mock_client.post = AsyncMock(return_value=mock_httpx_response(es_response)) + + await es_client.sugerir_consultores(tema="biologia", apenas_ativos=True) + + call_args = mock_client.post.call_args + query = call_args[1]["json"] + assert len(query["query"]["bool"]["must"]) >= 2 + + +class TestListarAreasAvaliacao: + @pytest.mark.asyncio + async def test_listar_areas_avaliacao(self, es_client): + result = await es_client.listar_areas_avaliacao() + + assert len(result) > 40 + assert any(a["nome"] == "CIÊNCIA DA COMPUTAÇÃO" for a in result) + assert any(a["nome"] == "MEDICINA I" for a in result) + assert all("count" in a for a in result) + + +class TestIntegracaoCompleta: + @pytest.mark.asyncio + async def test_fluxo_completo_consultor(self, es_client, mock_httpx_response): + doc_consultor = { + "id": 12345, + "dadosPessoais": { + "nome": "MARIA COORDENADORA SILVA", + "nascimento": "1970-05-15" + }, + "atuacoes": [ + { + "tipo": "Coordenação de Área de Avaliação", + "inicio": "01/01/2020", + "fim": None, + "dadosCoordenacaoArea": { + "tipo": "Coordenador de Área", + "areaAvaliacao": {"nome": "CIÊNCIA DA COMPUTAÇÃO"} + } + }, + { + "tipo": "Consultor", + "inicio": "01/01/2015", + "dadosConsultoria": { + "situacaoConsultoria": "Atividade Contínua" + } + } + ] + } + + with patch.object(es_client, '_client') as mock_client: + mock_client.is_closed = False + + mock_client.post = AsyncMock(return_value=mock_httpx_response({ + "hits": {"total": {"value": 1}, "hits": [{"_source": doc_consultor}]} + })) + + result = await es_client.buscar_por_id(12345) + + assert result["id"] == 12345 + assert len(result["atuacoes"]) == 2 + + coord = next(a for a in result["atuacoes"] if "Coordenação" in a["tipo"]) + assert coord["dadosCoordenacaoArea"]["tipo"] == "Coordenador de Área" + + cons = next(a for a in result["atuacoes"] if a["tipo"] == "Consultor") + assert cons["dadosConsultoria"]["situacaoConsultoria"] == "Atividade Contínua" diff --git a/backend/tests/infrastructure/repositories/__init__.py b/backend/tests/infrastructure/repositories/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/infrastructure/repositories/test_consultor_repository_impl.py b/backend/tests/infrastructure/repositories/test_consultor_repository_impl.py new file mode 100644 index 0000000..3f111c7 --- /dev/null +++ b/backend/tests/infrastructure/repositories/test_consultor_repository_impl.py @@ -0,0 +1,483 @@ +import pytest +from datetime import datetime +from unittest.mock import AsyncMock, MagicMock + +from src.infrastructure.repositories.consultor_repository_impl import ConsultorRepositoryImpl + + +@pytest.fixture +def mock_es_client(): + return AsyncMock() + + +@pytest.fixture +def repository(mock_es_client): + return ConsultorRepositoryImpl(es_client=mock_es_client) + + +class TestParseDate: + + def test_parse_date_formato_brasileiro(self, repository): + result = repository._parse_date("15/03/2020") + assert result is not None + assert result.day == 15 + assert result.month == 3 + assert result.year == 2020 + + def test_parse_date_formato_iso(self, repository): + result = repository._parse_date("2020-03-15") + assert result is not None + assert result.year == 2020 + assert result.month == 3 + assert result.day == 15 + + def test_parse_date_none(self, repository): + result = repository._parse_date(None) + assert result is None + + def test_parse_date_string_vazia(self, repository): + result = repository._parse_date("") + assert result is None + + def test_parse_date_invalida(self, repository): + result = repository._parse_date("data_invalida") + assert result is None + + +class TestInferirTipoCoordenacao: + + def test_coordenador_area(self, repository): + coord = {"dadosCoordenacaoArea": {"tipo": "Coordenador de Área"}} + result = repository._inferir_tipo_coordenacao(coord) + assert result == "CA" + + def test_coordenador_adjunto(self, repository): + coord = {"dadosCoordenacaoArea": {"tipo": "Coordenador Adjunto"}} + result = repository._inferir_tipo_coordenacao(coord) + assert result == "CAJ" + + def test_coordenador_adjunto_mp(self, repository): + coord = {"dadosCoordenacaoArea": {"tipo": "Coordenador Adjunto de Mestrado Profissionalizante"}} + result = repository._inferir_tipo_coordenacao(coord) + assert result == "CAJ_MP" + + def test_camara_tematica(self, repository): + coord = {"dadosCoordenacaoArea": {"tipo": "Câmara Temática"}} + result = repository._inferir_tipo_coordenacao(coord) + assert result == "CAM" + + def test_inferencia_por_descricao(self, repository): + coord = {"dadosCoordenacaoArea": {}, "descricao": "Coordenador Adjunto - MEDICINA"} + result = repository._inferir_tipo_coordenacao(coord) + assert result == "CAJ" + + def test_fallback_para_ca(self, repository): + coord = {"dadosCoordenacaoArea": {"tipo": "Tipo Desconhecido"}} + result = repository._inferir_tipo_coordenacao(coord) + assert result == "CA" + + +class TestInferirPremiacaoTipo: + + def test_grande_premio(self): + result = ConsultorRepositoryImpl._inferir_premiacao_tipo("Grande Prêmio CAPES") + assert result == "GP" + + def test_mencao_honrosa(self): + result = ConsultorRepositoryImpl._inferir_premiacao_tipo("Menção Honrosa") + assert result == "MENCAO" + + def test_premio_regular(self): + result = ConsultorRepositoryImpl._inferir_premiacao_tipo("Prêmio CAPES de Tese") + assert result == "PREMIO" + + def test_texto_sem_premiacao(self): + result = ConsultorRepositoryImpl._inferir_premiacao_tipo("Texto qualquer") + assert result is None + + def test_texto_vazio(self): + result = ConsultorRepositoryImpl._inferir_premiacao_tipo("") + assert result is None + + +class TestExtrairCoordenacoesCapes: + + def test_lista_vazia(self, repository): + result = repository._extrair_coordenacoes_capes([]) + assert result == [] + + def test_atuacao_sem_tipo_coordenacao(self, repository): + atuacoes = [{"tipo": "Consultor"}] + result = repository._extrair_coordenacoes_capes(atuacoes) + assert result == [] + + def test_coordenacao_ativa(self, repository): + atuacoes = [{ + "tipo": "Coordenação de Área de Avaliação", + "inicio": "01/01/2020", + "fim": None, + "dadosCoordenacaoArea": { + "tipo": "Coordenador de Área", + "inicioVinculacao": "01/01/2020", + "fimVinculacao": None, + "areaAvaliacao": {"nome": "CIÊNCIAS AMBIENTAIS"}, + } + }] + result = repository._extrair_coordenacoes_capes(atuacoes) + assert len(result) == 1 + assert result[0].codigo == "CA" + assert result[0].area_avaliacao == "CIÊNCIAS AMBIENTAIS" + assert result[0].periodo.ativo is True + + def test_coordenacao_historica(self, repository): + atuacoes = [{ + "tipo": "Histórico de Coordenação de Área de Avaliação", + "dadosCoordenacaoArea": { + "tipo": "Coordenador Adjunto", + "inicioVinculacao": "01/01/2018", + "fimVinculacao": "31/12/2020", + "areaAvaliacao": {"nome": "MEDICINA I"}, + } + }] + result = repository._extrair_coordenacoes_capes(atuacoes) + assert len(result) == 1 + assert result[0].codigo == "CAJ" + assert result[0].periodo.ativo is False + + def test_coordenacao_sem_inicio_ignorada(self, repository): + atuacoes = [{ + "tipo": "Coordenação de Área de Avaliação", + "dadosCoordenacaoArea": { + "tipo": "Coordenador de Área", + "inicioVinculacao": None, + "areaAvaliacao": {"nome": "CIÊNCIAS"}, + } + }] + result = repository._extrair_coordenacoes_capes(atuacoes) + assert len(result) == 0 + + +class TestExtrairConsultoria: + + def test_sem_consultoria(self, repository): + result = repository._extrair_consultoria([]) + assert result is None + + def test_consultor_ativo(self, repository): + atuacoes = [{ + "tipo": "Consultor", + "inicio": "01/01/2020", + "fim": None, + "dadosConsultoria": { + "situacaoConsultoria": "Atividade Contínua", + "inicioVinculacao": "01/01/2020", + "ies": {"id": "1", "nome": "USP", "sigla": "USP"}, + } + }] + result = repository._extrair_consultoria(atuacoes) + assert result is not None + assert result.codigo == "CONS_ATIVO" + assert result.periodo.ativo is True + + def test_consultor_historico(self, repository): + atuacoes = [{ + "tipo": "Histórico de Consultoria", + "inicio": "01/01/2015", + "fim": "31/12/2018", + "dadosConsultoria": { + "situacaoConsultoria": "Desligado", + "inicioVinculacao": "01/01/2015", + "inativacaoSituacao": "31/12/2018", + } + }] + result = repository._extrair_consultoria(atuacoes) + assert result is not None + assert result.codigo == "CONS_HIST" + + def test_consultor_falecido(self, repository): + atuacoes = [{ + "tipo": "Consultor", + "inicio": "01/01/2010", + "fim": "31/12/2020", + "dadosConsultoria": { + "situacaoConsultoria": "Falecido", + } + }] + result = repository._extrair_consultoria(atuacoes) + assert result is not None + assert result.codigo == "CONS_FALECIDO" + + +class TestExtrairInscricoes: + + def test_sem_inscricoes(self, repository): + result = repository._extrair_inscricoes([]) + assert result == [] + + def test_inscricao_autor(self, repository): + atuacoes = [{ + "tipo": "Inscrição Prêmio", + "inicio": "01/01/2024", + "dadosParticipacaoInscricaoPremio": { + "tipo": "Autor", + "nomePremio": "PCT", + "ano": 2024, + } + }] + result = repository._extrair_inscricoes(atuacoes) + assert len(result) == 1 + assert result[0].codigo == "INSC_AUTOR" + assert result[0].ano == 2024 + + def test_inscricao_institucional(self, repository): + atuacoes = [{ + "tipo": "Inscrição Prêmio", + "dadosParticipacaoInscricaoPremio": { + "tipo": "Coordenador PPG", + "nomePremio": "PCT", + "ano": 2024, + } + }] + result = repository._extrair_inscricoes(atuacoes) + assert len(result) == 1 + assert result[0].codigo == "INSC_INST_AUTOR" + + +class TestExtrairAvaliacoesComissao: + + def test_sem_avaliacoes(self, repository): + result = repository._extrair_avaliacoes_comissao([]) + assert result == [] + + def test_avaliador_premio(self, repository): + atuacoes = [{ + "tipo": "Avaliação Prêmio", + "dadosParticipacaoPremio": { + "tipo": "Membro de Comissão", + "nomePremio": "Prêmio CAPES de Tese", + "ano": 2024, + } + }] + result = repository._extrair_avaliacoes_comissao(atuacoes) + assert len(result) == 1 + assert result[0].codigo == "AVAL_COMIS_PREMIO" + + def test_avaliador_grande_premio(self, repository): + atuacoes = [{ + "tipo": "Avaliação Prêmio", + "dadosParticipacaoPremio": { + "tipo": "Membro de Comissão", + "nomePremio": "Grande Prêmio CAPES", + "ano": 2024, + } + }] + result = repository._extrair_avaliacoes_comissao(atuacoes) + assert len(result) == 1 + assert result[0].codigo == "AVAL_COMIS_GP" + + def test_coordenador_comissao(self, repository): + atuacoes = [{ + "tipo": "Avaliação Prêmio", + "dadosParticipacaoPremio": { + "tipo": "Coordenador/Presidente", + "nomePremio": "Prêmio CAPES", + "ano": 2024, + } + }] + result = repository._extrair_avaliacoes_comissao(atuacoes) + assert len(result) == 1 + assert result[0].codigo == "COORD_COMIS_PREMIO" + + +class TestExtrairPremiacoes: + + def test_sem_premiacoes(self, repository): + result = repository._extrair_premiacoes([]) + assert result == [] + + def test_grande_premio(self, repository): + atuacoes = [{ + "tipo": "Premiação Prêmio", + "dadosPremiacaoPremio": { + "tipoPremiacao": "Grande Prêmio", + "nomePremio": "Grande Prêmio CAPES de Tese", + "ano": 2024, + } + }] + result = repository._extrair_premiacoes(atuacoes) + assert len(result) == 1 + assert result[0].codigo == "PREMIACAO_GP_AUTOR" + + def test_premio_regular(self, repository): + atuacoes = [{ + "tipo": "Premiação Prêmio", + "dadosPremiacaoPremio": { + "tipoPremiacao": "Prêmio", + "nomePremio": "Prêmio CAPES de Tese", + "ano": 2024, + } + }] + result = repository._extrair_premiacoes(atuacoes) + assert len(result) == 1 + assert result[0].codigo == "PREMIACAO_AUTOR" + + def test_mencao_honrosa(self, repository): + atuacoes = [{ + "tipo": "Premiação Prêmio", + "dadosPremiacaoPremio": { + "tipoPremiacao": "Menção Honrosa", + "nomePremio": "PCT", + "ano": 2024, + } + }] + result = repository._extrair_premiacoes(atuacoes) + assert len(result) == 1 + assert result[0].codigo == "MENCAO_AUTOR" + + +class TestExtrairBolsasCNPQ: + + def test_sem_bolsas(self, repository): + result = repository._extrair_bolsas_cnpq([]) + assert result == [] + + def test_bolsa_cnpq(self, repository): + atuacoes = [{ + "tipo": "Bolsa CNPQ", + "dadosBolsa": { + "nivel": "1A", + "areaConhecimento": "Ciências Exatas", + } + }] + result = repository._extrair_bolsas_cnpq(atuacoes) + assert len(result) == 1 + assert result[0].codigo == "BOL_BPQ_NIVEL" + assert result[0].nivel == "1A" + + +class TestExtrairParticipacoes: + + def test_sem_participacoes(self, repository): + result = repository._extrair_participacoes([]) + assert result == [] + + def test_evento(self, repository): + atuacoes = [{ + "tipo": "Evento", + "descricao": "Seminário CAPES 2024", + "inicio": "01/06/2024", + }] + result = repository._extrair_participacoes(atuacoes) + assert len(result) == 1 + assert result[0].codigo == "EVENTO" + assert result[0].ano == 2024 + + def test_projeto(self, repository): + atuacoes = [{ + "tipo": "Projeto", + "descricao": "Projeto de Pesquisa", + "inicio": "01/01/2024", + }] + result = repository._extrair_participacoes(atuacoes) + assert len(result) == 1 + assert result[0].codigo == "PROJ" + + +class TestExtrairOrientacoes: + + def test_sem_orientacoes(self, repository): + result = repository._extrair_orientacoes([]) + assert result == [] + + def test_orientacao_discentes_contagem(self, repository): + atuacoes = [{ + "tipo": "Orientação de Discentes", + "dadosOrientacaoDiscente": { + "totalOrientacaoFinalizadaMestrado": 5, + "totalOrientacaoFinalizadaDoutorado": 3, + "totalAcompanhamentoPosDoutorado": 2, + } + }] + result = repository._extrair_orientacoes(atuacoes) + assert len(result) == 10 + pos_doc = [o for o in result if o.codigo == "ORIENT_POS_DOC"] + tese = [o for o in result if o.codigo == "ORIENT_TESE"] + diss = [o for o in result if o.codigo == "ORIENT_DISS"] + assert len(pos_doc) == 2 + assert len(tese) == 3 + assert len(diss) == 5 + + +class TestConstruirConsultor: + + @pytest.mark.asyncio + async def test_construir_consultor_completo(self, repository): + doc = { + "id": 12345, + "dadosPessoais": {"nome": "João da Silva", "cpf": "12345678900"}, + "atuacoes": [ + { + "tipo": "Coordenação de Área de Avaliação", + "inicio": "01/01/2020", + "dadosCoordenacaoArea": { + "tipo": "Coordenador de Área", + "inicioVinculacao": "01/01/2020", + "areaAvaliacao": {"nome": "CIÊNCIAS AMBIENTAIS"}, + } + }, + { + "tipo": "Consultor", + "inicio": "01/01/2018", + "dadosConsultoria": { + "situacaoConsultoria": "Atividade Contínua", + } + }, + ] + } + consultor = await repository._construir_consultor(doc) + assert consultor.id_pessoa == 12345 + assert consultor.nome == "João da Silva" + assert len(consultor.coordenacoes_capes) == 1 + assert consultor.consultoria is not None + assert consultor.pontuacao is not None + assert consultor.pontuacao_total > 0 + + @pytest.mark.asyncio + async def test_construir_consultor_vazio(self, repository): + doc = { + "id": 99999, + "dadosPessoais": {"nome": "Consultor Vazio"}, + "atuacoes": [] + } + consultor = await repository._construir_consultor(doc) + assert consultor.id_pessoa == 99999 + assert consultor.nome == "Consultor Vazio" + assert len(consultor.coordenacoes_capes) == 0 + assert consultor.consultoria is None + + +class TestBuscarPorId: + + @pytest.mark.asyncio + async def test_buscar_por_id_encontrado(self, repository, mock_es_client): + mock_es_client.buscar_por_id.return_value = { + "id": 12345, + "dadosPessoais": {"nome": "João"}, + "atuacoes": [] + } + result = await repository.buscar_por_id(12345) + assert result is not None + assert result.id_pessoa == 12345 + mock_es_client.buscar_por_id.assert_called_once_with(12345) + + @pytest.mark.asyncio + async def test_buscar_por_id_nao_encontrado(self, repository, mock_es_client): + mock_es_client.buscar_por_id.return_value = None + result = await repository.buscar_por_id(99999) + assert result is None + + @pytest.mark.asyncio + async def test_buscar_por_id_erro_es(self, repository, mock_es_client): + mock_es_client.buscar_por_id.side_effect = Exception("ES error") + result = await repository.buscar_por_id(12345) + assert result is None diff --git a/backend/tests/integration/test_es_repository_integration.py b/backend/tests/integration/test_es_repository_integration.py new file mode 100644 index 0000000..0a984ac --- /dev/null +++ b/backend/tests/integration/test_es_repository_integration.py @@ -0,0 +1,125 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock, patch +from httpx import Response + +from src.infrastructure.elasticsearch.client import ElasticsearchClient +from src.infrastructure.repositories.consultor_repository_impl import ConsultorRepositoryImpl +from src.domain.services.calculador_pontuacao import CalculadorPontuacao + + +def create_mock_response(json_data, status_code=200): + response = MagicMock(spec=Response) + response.json.return_value = json_data + response.status_code = status_code + response.raise_for_status = MagicMock() + return response + + +@pytest.fixture +def es_client(): + return ElasticsearchClient( + url="http://localhost:9200", + index="atuacapes_test", + user="test", + password="test" + ) + + +@pytest.fixture +def repository(es_client): + return ConsultorRepositoryImpl(es_client) + + +class TestIntegracaoCoordenadorDeArea: + + @pytest.mark.asyncio + async def test_coordenador_area_ativo_pontuacao_completa(self, es_client, repository): + doc_es = { + "id": 1001, + "dadosPessoais": {"nome": "COORDENADOR ATIVO SILVA"}, + "atuacoes": [{ + "tipo": "Coordenação de Área de Avaliação", + "inicio": "01/01/2020", + "fim": None, + "dadosCoordenacaoArea": { + "tipo": "Coordenador de Área", + "areaAvaliacao": {"nome": "CIÊNCIA DA COMPUTAÇÃO", "id": 1} + } + }] + } + + with patch.object(es_client, '_client') as mock_client: + mock_client.is_closed = False + mock_client.post = AsyncMock(return_value=create_mock_response({ + "hits": {"total": {"value": 1}, "hits": [{"_source": doc_es}]} + })) + + consultor = await repository.buscar_por_id(1001) + + assert consultor is not None + assert consultor.nome == "COORDENADOR ATIVO SILVA" + assert len(consultor.coordenacoes_capes) == 1 + + pontuacao = CalculadorPontuacao.calcular_pontuacao_completa(consultor) + assert pontuacao.bloco_a.total > 0 + + coord = consultor.coordenacoes_capes[0] + assert coord.codigo == "CA" + assert pontuacao.bloco_a.total >= 200 + + +class TestCenariosBorda: + + @pytest.mark.asyncio + async def test_consultor_sem_atuacoes(self, es_client, repository): + doc_es = { + "id": 8001, + "dadosPessoais": {"nome": "SEM ATUACOES"}, + "atuacoes": [] + } + + with patch.object(es_client, '_client') as mock_client: + mock_client.is_closed = False + mock_client.post = AsyncMock(return_value=create_mock_response({ + "hits": {"total": {"value": 1}, "hits": [{"_source": doc_es}]} + })) + + consultor = await repository.buscar_por_id(8001) + pontuacao = CalculadorPontuacao.calcular_pontuacao_completa(consultor) + + assert consultor.nome == "SEM ATUACOES" + assert pontuacao.total == 0 + + @pytest.mark.asyncio + async def test_atuacao_tipo_desconhecido(self, es_client, repository): + doc_es = { + "id": 8002, + "dadosPessoais": {"nome": "TIPO ESTRANHO"}, + "atuacoes": [{ + "tipo": "Tipo Inexistente No Sistema", + "dados": {"campo": "valor"} + }] + } + + with patch.object(es_client, '_client') as mock_client: + mock_client.is_closed = False + mock_client.post = AsyncMock(return_value=create_mock_response({ + "hits": {"total": {"value": 1}, "hits": [{"_source": doc_es}]} + })) + + consultor = await repository.buscar_por_id(8002) + pontuacao = CalculadorPontuacao.calcular_pontuacao_completa(consultor) + + assert pontuacao.total == 0 + + @pytest.mark.asyncio + async def test_consultor_nao_encontrado(self, es_client, repository): + with patch.object(es_client, '_client') as mock_client: + mock_client.is_closed = False + mock_client.post = AsyncMock(return_value=create_mock_response({ + "hits": {"total": {"value": 0}, "hits": []} + })) + + consultor = await repository.buscar_por_id(99999) + + assert consultor is None diff --git a/backend/tests/integration/test_scroll_flow.py b/backend/tests/integration/test_scroll_flow.py new file mode 100644 index 0000000..b42569a --- /dev/null +++ b/backend/tests/integration/test_scroll_flow.py @@ -0,0 +1,142 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock, patch +from httpx import Response + +from src.infrastructure.elasticsearch.client import ElasticsearchClient +from src.infrastructure.repositories.consultor_repository_impl import ConsultorRepositoryImpl +from src.domain.services.calculador_pontuacao import CalculadorPontuacao + + +def create_mock_response(json_data, status_code=200): + response = MagicMock(spec=Response) + response.json.return_value = json_data + response.status_code = status_code + response.raise_for_status = MagicMock() + return response + + +def criar_doc_consultor(id_pessoa, nome, atuacoes): + return { + "id": id_pessoa, + "dadosPessoais": {"nome": nome}, + "atuacoes": atuacoes + } + + +@pytest.fixture +def es_client(): + return ElasticsearchClient( + url="http://localhost:9200", + index="atuacapes_test" + ) + + +@pytest.fixture +def repository(es_client): + return ConsultorRepositoryImpl(es_client) + + +class TestScrollFlowCompleto: + + @pytest.mark.asyncio + async def test_processamento_scroll_com_ranking(self, es_client, repository): + docs_batch_1 = [ + criar_doc_consultor(1, "COORD AREA", [{ + "tipo": "Coordenação de Área de Avaliação", + "inicio": "01/01/2018", + "dadosCoordenacaoArea": {"tipo": "Coordenador de Área"} + }]), + criar_doc_consultor(2, "COORD ADJUNTO", [{ + "tipo": "Coordenação de Área de Avaliação", + "inicio": "01/01/2020", + "dadosCoordenacaoArea": {"tipo": "Coordenador Adjunto"} + }]), + ] + + docs_batch_2 = [ + criar_doc_consultor(3, "CONSULTOR ATIVO", [{ + "tipo": "Consultor", + "inicio": "01/01/2015", + "dadosConsultoria": {"situacaoConsultoria": "Atividade Contínua"} + }]), + criar_doc_consultor(4, "PESSOA SEM ATUACAO", []), + ] + + scroll_1 = { + "_scroll_id": "scroll_1", + "hits": { + "total": {"value": 4}, + "hits": [{"_source": d} for d in docs_batch_1] + } + } + + scroll_2 = { + "_scroll_id": "scroll_2", + "hits": {"hits": [{"_source": d} for d in docs_batch_2]} + } + + scroll_empty = {"_scroll_id": "scroll_3", "hits": {"hits": []}} + + ranking_results = [] + + async def processar_batch(docs, progress): + for doc in docs: + consultor = await repository._construir_consultor(doc) + pontuacao = CalculadorPontuacao.calcular_pontuacao_completa(consultor) + ranking_results.append({ + "id": consultor.id_pessoa, + "nome": consultor.nome, + "total": pontuacao.total + }) + + with patch.object(es_client, '_client') as mock_client: + mock_client.is_closed = False + mock_client.post = AsyncMock(side_effect=[ + create_mock_response(scroll_1), + create_mock_response(scroll_2), + create_mock_response(scroll_empty) + ]) + mock_client.delete = AsyncMock(return_value=create_mock_response({})) + + result = await es_client.buscar_todos_consultores(processar_batch, batch_size=2) + + assert result["total"] == 4 + assert result["processados"] == 4 + assert len(ranking_results) == 4 + + +class TestProgressoScroll: + + @pytest.mark.asyncio + async def test_progress_callback(self, es_client): + progress_history = [] + + async def callback(docs, progress): + progress_history.append(progress.copy()) + + scroll_1 = { + "_scroll_id": "s1", + "hits": {"total": {"value": 100}, "hits": [{"_source": {"id": i}} for i in range(50)]} + } + scroll_2 = { + "_scroll_id": "s2", + "hits": {"hits": [{"_source": {"id": i}} for i in range(50, 100)]} + } + scroll_empty = {"_scroll_id": "s3", "hits": {"hits": []}} + + with patch.object(es_client, '_client') as mock_client: + mock_client.is_closed = False + mock_client.post = AsyncMock(side_effect=[ + create_mock_response(scroll_1), + create_mock_response(scroll_2), + create_mock_response(scroll_empty) + ]) + mock_client.delete = AsyncMock(return_value=create_mock_response({})) + + await es_client.buscar_todos_consultores(callback, batch_size=50) + + assert len(progress_history) == 2 + assert progress_history[0]["percentual"] == 50 + assert progress_history[0]["processados"] == 50 + assert progress_history[1]["percentual"] == 100 + assert progress_history[1]["processados"] == 100 diff --git a/backend/tests/interface/__init__.py b/backend/tests/interface/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/interface/api/__init__.py b/backend/tests/interface/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/interface/api/test_routes.py b/backend/tests/interface/api/test_routes.py new file mode 100644 index 0000000..6f17830 --- /dev/null +++ b/backend/tests/interface/api/test_routes.py @@ -0,0 +1,301 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock, patch +from fastapi.testclient import TestClient +from fastapi import FastAPI + +from src.interface.api.routes import router +from src.interface.api.dependencies import ( + get_repository, + get_ranking_store, + get_processar_job, + get_es_client, + get_ranking_oracle_repo, +) + + +@pytest.fixture +def mock_repository(): + repo = AsyncMock() + repo.contar_total.return_value = 100 + return repo + + +@pytest.fixture +def mock_ranking_store(): + store = MagicMock() + store.is_ready.return_value = False + return store + + +@pytest.fixture +def mock_oracle_repo(): + repo = MagicMock() + repo.contar_total.return_value = 1000 + return repo + + +@pytest.fixture +def mock_es_client(): + return AsyncMock() + + +@pytest.fixture +def mock_job(): + return AsyncMock() + + +@pytest.fixture +def app(mock_repository, mock_ranking_store, mock_oracle_repo, mock_es_client, mock_job): + app = FastAPI() + app.include_router(router) + + app.dependency_overrides[get_repository] = lambda: mock_repository + app.dependency_overrides[get_ranking_store] = lambda: mock_ranking_store + app.dependency_overrides[get_ranking_oracle_repo] = lambda: mock_oracle_repo + app.dependency_overrides[get_es_client] = lambda: mock_es_client + app.dependency_overrides[get_processar_job] = lambda: mock_job + + return app + + +@pytest.fixture +def client(app): + return TestClient(app) + + +class TestHealthCheck: + + def test_health_check_ok(self, client): + response = client.get("/api/v1/health") + assert response.status_code == 200 + data = response.json() + assert data["status"] == "ok" + + +class TestRankingStatus: + + def test_status_inicial(self, client): + with patch("src.interface.api.routes.job_status") as mock_status: + mock_status.to_dict.return_value = { + "running": False, + "progress": 0, + "processados": 0, + "total": 0, + "mensagem": "", + "batch_atual": 0, + "total_batches": 0, + "tempo_decorrido": None, + "tempo_estimado": None, + "inicio": None, + "fim": None, + "erro": None, + } + response = client.get("/api/v1/ranking/status") + assert response.status_code == 200 + data = response.json() + assert data["running"] is False + + +class TestRankingPaginado: + + def test_ranking_paginado_sem_oracle(self, client, mock_oracle_repo): + mock_oracle_repo.contar_total.return_value = 0 + response = client.get("/api/v1/ranking/paginado") + assert response.status_code == 503 + + def test_ranking_paginado_com_dados(self, client, mock_oracle_repo): + mock_oracle_repo.contar_total.return_value = 100 + mock_oracle_repo.buscar_paginado.return_value = [ + MagicMock( + id_pessoa=1, + nome="Consultor 1", + posicao=1, + pontuacao_total=500, + componente_a=200, + componente_b=100, + componente_c=100, + componente_d=50, + componente_e=50, + ativo=True, + anos_atuacao=5.0, + json_detalhes="{}" + ) + ] + response = client.get("/api/v1/ranking/paginado?page=1&size=10") + assert response.status_code == 200 + data = response.json() + assert data["total"] == 100 + assert len(data["consultores"]) == 1 + + def test_ranking_paginado_filtro_ativo(self, client, mock_oracle_repo): + mock_oracle_repo.contar_total.return_value = 50 + mock_oracle_repo.buscar_paginado.return_value = [] + response = client.get("/api/v1/ranking/paginado?ativo=true") + assert response.status_code == 200 + + def test_ranking_paginado_filtro_selos(self, client, mock_oracle_repo): + mock_oracle_repo.contar_total.return_value = 0 + response = client.get("/api/v1/ranking/paginado?selos=COORD_PPG,BPQ") + assert response.status_code == 200 + data = response.json() + assert data["total"] == 0 + + +class TestBuscaPorNome: + + def test_busca_por_nome_encontrado(self, client, mock_oracle_repo): + mock_oracle_repo.buscar_por_nome.return_value = [ + {"ID_PESSOA": 1, "NOME": "João Silva", "POSICAO": 10, "PONTUACAO_TOTAL": 500} + ] + response = client.get("/api/v1/ranking/busca?nome=João") + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + assert data[0]["nome"] == "João Silva" + + def test_busca_por_nome_nao_encontrado(self, client, mock_oracle_repo): + mock_oracle_repo.buscar_por_nome.return_value = [] + response = client.get("/api/v1/ranking/busca?nome=XYZ") + assert response.status_code == 200 + data = response.json() + assert len(data) == 0 + + def test_busca_por_nome_minimo_3_chars(self, client): + response = client.get("/api/v1/ranking/busca?nome=AB") + assert response.status_code == 422 + + +class TestRankingEstatisticas: + + def test_estatisticas_sem_dados(self, client, mock_oracle_repo): + mock_oracle_repo.contar_total.return_value = 0 + response = client.get("/api/v1/ranking/estatisticas") + assert response.status_code == 503 + + def test_estatisticas_com_dados(self, client, mock_oracle_repo): + mock_oracle_repo.contar_total.return_value = 1000 + mock_oracle_repo.obter_estatisticas.return_value = { + "total_consultores": 1000, + "total_ativos": 800, + "total_inativos": 200, + "ultima_atualizacao": "2024-01-01", + "pontuacao_media": 150.5, + "pontuacao_maxima": 850, + "pontuacao_minima": 10, + "media_componentes": {"a": 50, "b": 30, "c": 40, "d": 20, "e": 10} + } + mock_oracle_repo.obter_distribuicao.return_value = [ + {"faixa": "0-100", "quantidade": 500}, + {"faixa": "100-200", "quantidade": 300}, + ] + response = client.get("/api/v1/ranking/estatisticas") + assert response.status_code == 200 + data = response.json() + assert data["total_consultores"] == 1000 + assert data["total_ativos"] == 800 + + +class TestPosicaoRanking: + + def test_posicao_encontrada(self, client, mock_oracle_repo): + mock_oracle_repo.contar_total.return_value = 1000 + mock_oracle_repo.buscar_por_id.return_value = MagicMock( + id_pessoa=123, + nome="João Silva", + posicao=42, + pontuacao_total=500, + componente_a=200, + componente_b=100, + componente_c=100, + componente_d=50, + componente_e=50, + ativo=True, + ) + response = client.get("/api/v1/ranking/posicao/123") + assert response.status_code == 200 + data = response.json() + assert data["id_pessoa"] == 123 + assert data["posicao"] == 42 + assert data["encontrado"] is True + + def test_posicao_nao_encontrada(self, client, mock_oracle_repo): + mock_oracle_repo.contar_total.return_value = 1000 + mock_oracle_repo.buscar_por_id.return_value = None + response = client.get("/api/v1/ranking/posicao/99999") + assert response.status_code == 200 + data = response.json() + assert data["encontrado"] is False + + +class TestProcessarRanking: + + def test_processar_ranking_inicia(self, client, mock_job): + with patch("src.interface.api.routes.job_status") as mock_status: + mock_status.is_running = False + response = client.post("/api/v1/ranking/processar") + assert response.status_code == 200 + data = response.json() + assert data["sucesso"] is True + + def test_processar_ranking_ja_executando(self, client): + with patch("src.interface.api.routes.job_status") as mock_status: + mock_status.is_running = True + response = client.post("/api/v1/ranking/processar") + assert response.status_code == 409 + + +class TestListarSelos: + + def test_listar_selos(self, client): + response = client.get("/api/v1/ranking/selos") + assert response.status_code == 200 + data = response.json() + assert "selos" in data + assert isinstance(data["selos"], list) + + +class TestExportarInfo: + + def test_info_exportacao(self, client, mock_oracle_repo): + mock_oracle_repo.contar_para_exportacao.return_value = 500 + response = client.get("/api/v1/ranking/exportar/info") + assert response.status_code == 200 + data = response.json() + assert data["total_consultores"] == 500 + assert "estimativa_tamanho_mb" in data + + +class TestCorrigirEncoding: + + def test_corrigir_encoding_sem_problemas(self): + from src.interface.api.routes import corrigir_encoding + result = corrigir_encoding("Texto normal") + assert result == "Texto normal" + + def test_corrigir_encoding_none(self): + from src.interface.api.routes import corrigir_encoding + result = corrigir_encoding(None) + assert result is None + + def test_corrigir_encoding_vazio(self): + from src.interface.api.routes import corrigir_encoding + result = corrigir_encoding("") + assert result == "" + + +class TestNormalizarTexto: + + def test_normalizar_texto_acentos(self): + from src.interface.api.routes import normalizar_texto + result = normalizar_texto("Ciências Ambientais") + assert result == "ciencias ambientais" + + def test_normalizar_texto_html_entities(self): + from src.interface.api.routes import normalizar_texto + result = normalizar_texto("Ciências") + assert "ciencias" in result.lower() + + def test_normalizar_texto_vazio(self): + from src.interface.api.routes import normalizar_texto + result = normalizar_texto("") + assert result == ""