""" Suíte de Testes Automatizados para Conversão de Artigo JSON para Markdown. Cobre 100% dos Requisitos Funcionais (FR-001 a FR-018), Requisitos Não Funcionais (RNF-001 a RNF-006), Critérios de Aceite (CA-001 a CA-013), Casos de Teste do PRD (CT-001 a CT-012), Matriz de Erros (12 condições), Testes Unitários de Prioridade/Normalização, Testes de Integração de Arquivo e Testes E2E de Pipeline via subprocess. """ from __future__ import annotations import hashlib import json import subprocess import sys from pathlib import Path import pytest from scripts.convert_article_to_markdown import ( assemble_markdown_document, clean_body_images, convert_article, convert_html_to_markdown, normalize_date, normalize_list, normalize_scalar, parse_arguments, remove_duplicate_initial_h1, resolve_article_body, resolve_article_metadata, validate_url, ) FIXTURES_DIR = Path(__file__).parent / "fixtures" / "markdown_conversion" SCRIPT_PATH = Path(__file__).parent.parent / "scripts" / "convert_article_to_markdown.py" # ============================================================================== # 1. Testes Unitários de Normalização e Sanitização Escalar # ============================================================================== def test_normalize_scalar_complex_html_entities(): """Valida decodificação de entidades HTML nomeadas e numéricas.""" assert ( normalize_scalar("River & Boca "Superclásico"") == 'River & Boca "Superclásico"' ) assert normalize_scalar("Preço: R$ 50,00 €") == "Preço: R$ 50,00 €" def test_normalize_scalar_whitespace_collapsing(): """Valida colapso de tabs, quebras de linha e espaços múltiplos em um único espaço.""" assert ( normalize_scalar(" Texto com \t\t múltiplos \n\n espaços ") == "Texto com múltiplos espaços" ) @pytest.mark.parametrize( "placeholder", [ "null", "Null", "NULL", "none", "None", "NONE", "n/a", "N/A", "N/a", "unknown", "Unknown", "UNKNOWN", "[no-author]", "[No-Author]", "[NO-AUTHOR]", "no-author", "No-Author", "NO-AUTHOR", ], ) def test_normalize_scalar_placeholders_discarded(placeholder: str): """Garante que todos os placeholders documentados no PRD sejam descartados (retornando None).""" assert normalize_scalar(placeholder) is None assert normalize_scalar(f" {placeholder} ") is None def test_normalize_scalar_non_string_types(): """Valida que entradas não string retornem None de forma segura.""" assert normalize_scalar(None) is None assert normalize_scalar(12345) is None assert normalize_scalar(["lista"]) is None assert normalize_scalar({"chave": "valor"}) is None # ============================================================================== # 2. Testes Unitários de Normalização de Listas # ============================================================================== def test_normalize_list_semicolon_and_comma_split(): """Testa divisão por ponto e vírgula na string e vírgulas em elementos de lista para tags/categorias.""" raw_str = "Futebol; Copa Libertadores; Conmebol; Notícias de Hoje" expected_str = ["Futebol", "Copa Libertadores", "Conmebol", "Notícias de Hoje"] assert normalize_list(raw_str) == expected_str raw_list = ["Futebol", "Copa Libertadores, Conmebol", "Notícias de Hoje"] expected_list = ["Futebol", "Copa Libertadores", "Conmebol", "Notícias de Hoje"] assert normalize_list(raw_list) == expected_list def test_normalize_list_author_url_filtering(): """Garante que URLs em campos de autor sejam estritamente descartadas.""" raw_authors = [ "Ernesto Provitilo", "https://twitter.com/eprovitilo", "http://www.instagram.com/reporter", "www.tycsports.com/autor", "Juan Pablo Varsky", ] result = normalize_list(raw_authors, is_author=True) assert result == ["Ernesto Provitilo", "Juan Pablo Varsky"] def test_normalize_list_deduplication_preserves_case_and_order(): """Testa deduplicação case-insensitive preservando a grafia e ordem da primeira ocorrência.""" items = ["River Plate", "Boca Juniors", "river plate", "RIVER PLATE", "boca juniors", "Racing"] assert normalize_list(items) == ["River Plate", "Boca Juniors", "Racing"] def test_normalize_list_empty_and_invalid(): """Testa comportamento com listas vazias, nulas ou contendo apenas placeholders.""" assert normalize_list([]) == [] assert normalize_list(None) == [] assert normalize_list(["n/a", "unknown", "[no-author]", " "]) == [] # ============================================================================== # 3. Testes Unitários de Parsing de Datas # ============================================================================== def test_normalize_date_iso_8601_variants(): """Valida parsing de datas ISO 8601 em múltiplos formatos e fusos.""" assert normalize_date("2026-08-20T00:36:33-03:00") == "2026-08-20T00:36:33-03:00" assert normalize_date("2026-08-20T03:36:33+00:00") == "2026-08-20T03:36:33+00:00" # Data pura sem hora assert normalize_date("2026-08-20") == "2026-08-20" def test_normalize_date_rfc_2822_variants(): """Valida parsing de datas no formato RFC 2822 (usado em feeds RSS e cabeçalhos HTTP).""" d1 = normalize_date("Thu, 20 Aug 2026 03:27:26 GMT") assert d1 is not None and "2026-08-20" in d1 d2 = normalize_date("Wed, 19 Aug 2026 21:00:00 -0300") assert d2 is not None and "2026-08-19" in d2 def test_normalize_date_invalid_and_placeholders(): """Garante que datas inválidas ou placeholders retornem None sem lançar exceção não tratada.""" assert normalize_date("data-invalida") is None assert normalize_date("2026/99/99") is None assert normalize_date("n/a") is None assert normalize_date(None) is None assert normalize_date(123456789) is None # ============================================================================== # 4. Testes Unitários de Validação de URLs # ============================================================================== @pytest.mark.parametrize( "valid_url", [ "https://www.tycsports.com/river-plate/los-puntajes.html", "http://globoesporte.globo.com/futebol/times/flamengo", "https://sub.dominio.co.uk:8080/path/to/resource?param=1&query=test#hash", "https://example.com/noticia-com-acentos-%C3%A1%C3%A9%C3%AD", ], ) def test_validate_url_valid_schemes(valid_url: str): """Garante aceitação de URLs absolutas com esquema HTTP e HTTPS válidos.""" assert validate_url(valid_url) == valid_url @pytest.mark.parametrize( "invalid_url", [ "ftp://ftp.is.co.za/rfc/rfc1808.txt", "file:///C:/Users/test/file.txt", "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA", "javascript:alert('xss')", "/caminho/relativo/artigo.html", "http://", "https://", "", " ", None, ], ) def test_validate_url_invalid_schemes(invalid_url: str | None): """Garante rejeição de esquemas não permitidos, URLs relativas e strings vazias.""" assert validate_url(invalid_url) is None # ============================================================================== # 5. Testes Unitários de Conversão HTML para Markdown # ============================================================================== def test_convert_html_to_markdown_rich_formatting(): """Valida conversão de elementos HTML estruturados para Markdown com títulos ATX.""" html_raw = ( "
Parágrafo com negrito, forte, itálico e ênfase.
" "Uma citação memorável." "
Link para o portal oficial.
" "codigo_inline()"
""
""
"Artigo em HTML
", "text": "Artigo em texto puro", }, } assert "**HTML**" in resolve_article_body(art1) # Fallback art2 = { "selected_extractor": "newspaper4k", "newspaper4k": {"article_html": None, "text": "Artigo em texto puro Newspaper"}, } assert resolve_article_body(art2) == "Artigo em texto puro Newspaper" def test_resolve_article_body_readability_primary_and_fallback(): """Testa prioridade readability.cleaned_html sobre readability.cleaned_text.""" # Primário HTML -> MD art1 = { "selected_extractor": "readability", "readability": { "cleaned_html": "Conteúdo Readability
", "cleaned_text": "Texto puro Readability", }, } body = resolve_article_body(art1) assert "*Readability*" in body or "_Readability_" in body # Fallback art2 = { "selected_extractor": "readability", "readability": {"cleaned_html": "", "cleaned_text": "Texto puro Readability Fallback"}, } assert resolve_article_body(art2) == "Texto puro Readability Fallback" @pytest.mark.parametrize("extractor", ["trafilatura", "newspaper4k", "readability"]) def test_resolve_article_body_strict_isolation_all_extractors(extractor: str): """Garante que a ausência de corpo no extrator selecionado NUNCA faça fallback para outro extrator.""" article = { "selected_extractor": extractor, "trafilatura": {"markdown": "Texto Trafilatura", "text": "Texto Trafilatura"}, "newspaper4k": {"article_html": "Texto Newspaper
", "text": "Texto Newspaper"}, "readability": { "cleaned_html": "Texto Readability
", "cleaned_text": "Texto Readability", }, } # Esvazia o corpo do extrator selecionado if extractor == "trafilatura": article["trafilatura"] = {"markdown": None, "text": ""} elif extractor == "newspaper4k": article["newspaper4k"] = {"article_html": "", "text": None} elif extractor == "readability": article["readability"] = {"cleaned_html": None, "cleaned_text": ""} with pytest.raises(ValueError, match="Corpo do extrator selecionado.*vazio|indisponível"): resolve_article_body(article) def test_resolve_article_body_invalid_selected_extractor(): """Garante erro ao receber selected_extractor ausente ou não reconhecido.""" with pytest.raises(ValueError, match="selected_extractor inválido ou ausente"): resolve_article_body({"selected_extractor": "extrator_desconhecido"}) with pytest.raises(ValueError, match="selected_extractor inválido ou ausente"): resolve_article_body({"selected_extractor": None}) # ============================================================================== # 7. Testes da Matriz Determinística de Resolução de Metadados # ============================================================================== def test_metadata_priority_title_all_fallbacks(): """Valida a cadeia de fallback completa para o campo TÍTULO (6 níveis).""" # 1. Do selecionado art1 = {"selected_extractor": "trafilatura", "trafilatura": {"title": "Título Selecionado"}} assert ( resolve_article_metadata({**art1, "crawled_url": "https://e.com"})["title"] == "Título Selecionado" ) # 2. input_meta.titulo art2 = { "selected_extractor": "trafilatura", "input_meta": {"titulo": "Título Input Meta"}, "crawled_url": "https://e.com", } assert resolve_article_metadata(art2)["title"] == "Título Input Meta" # 3. page_title art3 = { "selected_extractor": "trafilatura", "page_title": "Título Page Title", "crawled_url": "https://e.com", } assert resolve_article_metadata(art3)["title"] == "Título Page Title" # 4. newspaper4k.title art4 = { "selected_extractor": "trafilatura", "newspaper4k": {"title": "Título Newspaper"}, "crawled_url": "https://e.com", } assert resolve_article_metadata(art4)["title"] == "Título Newspaper" # 5. readability.title art5 = { "selected_extractor": "trafilatura", "readability": {"title": "Título Readability"}, "crawled_url": "https://e.com", } assert resolve_article_metadata(art5)["title"] == "Título Readability" def test_metadata_priority_original_url_all_fallbacks(): """Valida a cadeia de fallback completa para a URL ORIGINAL (5 níveis).""" # 1. input_meta.url art1 = { "selected_extractor": "trafilatura", "trafilatura": {"title": "T"}, "input_meta": {"url": "https://example.com/input-meta"}, "crawled_url": "https://example.com/crawled", } assert resolve_article_metadata(art1)["original_url"] == "https://example.com/input-meta" # 2. crawled_url art2 = { "selected_extractor": "trafilatura", "trafilatura": {"title": "T"}, "crawled_url": "https://example.com/crawled", } assert resolve_article_metadata(art2)["original_url"] == "https://example.com/crawled" # 3. Canonical do selecionado art3 = { "selected_extractor": "trafilatura", "trafilatura": {"title": "T", "canonical_url": "https://example.com/canonical-trafilatura"}, } assert ( resolve_article_metadata(art3)["original_url"] == "https://example.com/canonical-trafilatura" ) # 4. Canonical do newspaper4k art4 = { "selected_extractor": "readability", "readability": {"title": "T"}, "newspaper4k": {"canonical_link": "https://example.com/canonical-newspaper"}, } assert ( resolve_article_metadata(art4)["original_url"] == "https://example.com/canonical-newspaper" ) def test_metadata_priority_subtitle_omitted_when_equal_to_title(): """Garante que subtítulo idêntico ao título seja automaticamente omitido (None).""" art = { "selected_extractor": "trafilatura", "trafilatura": { "title": "Grande Vitória no Clássico", "description": " grande vitória no clássico ", }, "crawled_url": "https://example.com/noticia", } meta = resolve_article_metadata(art) assert meta["title"] == "Grande Vitória no Clássico" assert meta["subtitle"] is None def test_metadata_priority_first_valid_source_no_cross_merging(): """Garante que listas de autores/tags usem apenas a primeira fonte válida, sem merge cruzado.""" art = { "selected_extractor": "readability", "crawled_url": "https://example.com/noticia", "readability": {"title": "Título", "author": "Carlos Bilardo"}, "newspaper4k": {"authors": ["Juan Pérez", "María Gómez"]}, } meta = resolve_article_metadata(art) # Deve pegar o autor de Readability (selecionado), sem misturar com Newspaper4k assert meta["authors"] == ["Carlos Bilardo"] # ============================================================================== # 8. Testes de Higienização de Cabeçalhos e Imagens no Corpo # ============================================================================== def test_remove_duplicate_initial_h1_exact_and_variations(): """Testa remoção de H1 inicial coincidente com título com variações de espaços e caixa.""" title = "River Plate Conquista a Copa" # H1 inicial igual body1 = "# river plate conquista a copa\n\nPrimeiro parágrafo do artigo." assert remove_duplicate_initial_h1(body1, title).strip() == "Primeiro parágrafo do artigo." # H1 inicial diferente (deve ser preservado) body2 = "# Outro Título Diferente\n\nPrimeiro parágrafo." assert remove_duplicate_initial_h1(body2, title) == body2 # Sem H1 inicial body3 = "Parágrafo sem nenhum título H1 inicial." assert remove_duplicate_initial_h1(body3, title) == body3 def test_clean_body_images_removes_invalid_and_deduplicates(): """Valida descarte de data:, relativos e deduplicação mantendo a primeira ocorrência.""" body = ( "\n\n" "\n\n" "\n\n" "\n\n" "" ) cleaned = clean_body_images(body) assert cleaned.count("https://example.com/img1.jpg") == 1 assert "https://example.com/img2.webp" in cleaned assert "/imagem.png" not in cleaned assert "data:image" not in cleaned # ============================================================================== # 9. Testes de Montagem e Formatação do Documento Markdown # ============================================================================== def test_assemble_markdown_document_full_and_minimal(): """Testa montagem com todos os campos e apenas com campos obrigatórios.""" # Artigo Mínimo (apenas Título e URL Original) min_meta = { "title": "Título Mínimo", "original_url": "https://example.com/minimo", "subtitle": None, "authors": [], "publish_date": None, "site_name": None, "categories": [], "tags": [], "keywords": [], "language": None, "top_image": None, } doc_min = assemble_markdown_document(min_meta, "Corpo do texto simples.") assert doc_min.startswith("# Título Mínimo\n\n") assert "**Fonte original:** [https://example.com/minimo](https://example.com/minimo)" in doc_min assert "**Autor:**" not in doc_min assert "**Site:**" not in doc_min assert "![Imagem principal]" not in doc_min assert "\n\n---\n\nCorpo do texto simples.\n" in doc_min assert doc_min.endswith("\n") assert not doc_min.endswith("\n\n") # ============================================================================== # 10. Golden Test Fixtures (Conformidade 100% Byte a Byte) # ============================================================================== def test_golden_fixtures_byte_level_precision(tmp_path): """Garante correspondência exata byte a byte para Trafilatura, Newspaper4k e Readability.""" for extractor in ["trafilatura", "newspaper4k", "readability"]: input_json = FIXTURES_DIR / f"valid_{extractor}.json" expected_md = (FIXTURES_DIR / f"valid_{extractor}.md").read_text(encoding="utf-8") output_md = tmp_path / f"valid_{extractor}.md" res = subprocess.run( [sys.executable, str(SCRIPT_PATH), "-i", str(input_json), "-o", str(output_md)], capture_output=True, text=True, ) assert res.returncode == 0, f"Erro no extrator {extractor}: {res.stderr}" generated_md = output_md.read_text(encoding="utf-8") assert generated_md == expected_md, f"Divergência byte a byte na fixture {extractor}" def test_conversion_determinism_sha256_repeatability(tmp_path): """Garante que múltiplas execuções no mesmo arquivo produzam hashes SHA-256 idênticos.""" input_json = FIXTURES_DIR / "valid_trafilatura.json" hashes = set() for i in range(5): out_md = tmp_path / f"deterministic_{i}.md" res = subprocess.run( [sys.executable, str(SCRIPT_PATH), "-i", str(input_json), "-o", str(out_md)], capture_output=True, text=True, ) assert res.returncode == 0 content_bytes = out_md.read_bytes() hashes.add(hashlib.sha256(content_bytes).hexdigest()) assert len(hashes) == 1, "A conversão não foi 100% determinística entre execuções repetidas." # ============================================================================== # 11. Testes de Integração CLI, Validação e Códigos de Saída # ============================================================================== def test_cli_exit_codes_and_error_handling(tmp_path): """Testa toda a matriz de códigos de saída da CLI (0, 1, 2).""" # Código 2: Sintaxe / Argumentos Faltantes res_no_args = subprocess.run([sys.executable, str(SCRIPT_PATH)], capture_output=True, text=True) assert res_no_args.returncode == 2 # Código 1: Arquivo Inexistente res_missing_file = subprocess.run( [sys.executable, str(SCRIPT_PATH), "-i", "arquivo_que_nao_existe_xyz.json"], capture_output=True, text=True, ) assert res_missing_file.returncode == 1 assert "não encontrado" in res_missing_file.stderr.lower() # Código 1: Rejeição de Batch com chave 'articles' res_batch = subprocess.run( [sys.executable, str(SCRIPT_PATH), "-i", str(FIXTURES_DIR / "batch_articles_invalid.json")], capture_output=True, text=True, ) assert res_batch.returncode == 1 assert "articles" in res_batch.stderr.lower() # Código 1: JSON Corrompido res_corrupt = subprocess.run( [sys.executable, str(SCRIPT_PATH), "-i", str(FIXTURES_DIR / "corrupt_json_invalid.json")], capture_output=True, text=True, ) assert res_corrupt.returncode == 1 assert "json" in res_corrupt.stderr.lower() def test_cli_json_root_must_be_object(tmp_path): """Garante encerramento com código 1 caso a raiz do JSON seja lista, número ou string.""" for invalid_root in [["item1", "item2"], 12345, "string simples"]: bad_json = tmp_path / "bad_root.json" bad_json.write_text(json.dumps(invalid_root), encoding="utf-8") res = subprocess.run( [sys.executable, str(SCRIPT_PATH), "-i", str(bad_json)], capture_output=True, text=True, ) assert res.returncode == 1 assert "objeto" in res.stderr.lower() or "dict" in res.stderr.lower() def test_cli_no_stdout_pollution_and_atomic_preservation(tmp_path): """Garante que stdout permaneça limpo e gravação atômica preserve arquivos preexistentes em falha.""" target_md = tmp_path / "target_document.md" target_md.write_text("Versão Original Preservada", encoding="utf-8") # Executa conversão bem-sucedida res_ok = subprocess.run( [ sys.executable, str(SCRIPT_PATH), "-i", str(FIXTURES_DIR / "valid_trafilatura.json"), "-o", str(target_md), ], capture_output=True, text=True, ) assert res_ok.returncode == 0 assert res_ok.stdout == "" # Não polui stdout assert "[INFO]" in res_ok.stderr # Executa falha direcionada ao mesmo target res_fail = subprocess.run( [ sys.executable, str(SCRIPT_PATH), "-i", str(FIXTURES_DIR / "missing_body_invalid.json"), "-o", str(target_md), ], capture_output=True, text=True, ) assert res_fail.returncode == 1 # O arquivo target deve manter o conteúdo do sucesso anterior, não foi apagado/corrompido assert "# Los puntajes de River" in target_md.read_text(encoding="utf-8") # Verifica que não há arquivos temporários .tmp no diretório tmp_files = list(tmp_path.glob("*.tmp")) assert len(tmp_files) == 0 def test_cli_default_output_naming(tmp_path): """Garante geração automática de