✨ Nuove funzionalità: - Nuovo endpoint API REST /api/v1/query (GET+POST) per consumo programmatico - Campo speakable_text ottimizzato per sintesi vocale (Alexa, Google Home) - Funzione strip_markdown() per conversione Markdown → plain text TTS - Supporto CORS per accesso cross-origin - UI responsive mobile-first con micro-interazioni e stati - Nuovo pulsante 'Nuova ricerca' e metadata bar 📚 Documentazione completa: - docs/ARCHITECTURE.md — architettura di sistema, stack, componenti - docs/FLOW.md — diagrammi Mermaid della pipeline e flussi - docs/API.md — documentazione endpoint REST con esempi curl - docs/WIKI.md — FAQ, glossario, roadmap - docs/DEPLOYMENT.md — guida deployment (Gunicorn, Nginx, Docker, systemd) - docs/CONTRIBUTING.md — guida alla contribuzione 🔧 Migliorie tecniche: - Pipeline di ricerca condivisa in _run_search_pipeline() - Gestione errori Gemini migliorata con _clean_error() - Sanitizzazione HTML con allowlist per prevenzione XSS - .env.example completo con commenti 🧪 Test: 109/109 passati
265 lines
9.1 KiB
Python
265 lines
9.1 KiB
Python
"""Tests for sibilla.markdown — server-side Markdown → HTML conversion."""
|
|
|
|
from sibilla.markdown import render, strip_markdown
|
|
|
|
|
|
class TestBasicMarkdown:
|
|
def test_empty_returns_empty(self):
|
|
assert render("") == ""
|
|
assert render(None) == ""
|
|
|
|
def test_plain_text(self):
|
|
result = render("Ciao mondo")
|
|
assert "Ciao mondo" in result
|
|
assert result.startswith("<p>")
|
|
|
|
def test_bold(self):
|
|
result = render("Test **bold** text")
|
|
assert "<strong>bold</strong>" in result
|
|
|
|
def test_italic(self):
|
|
result = render("Test *italic* text")
|
|
assert "<em>italic</em>" in result
|
|
|
|
def test_bold_italic(self):
|
|
result = render("Test ***bold italic*** text")
|
|
assert "<strong>" in result
|
|
assert "<em>" in result
|
|
|
|
def test_inline_code(self):
|
|
result = render("Use `print()` function")
|
|
assert "<code>print()</code>" in result
|
|
|
|
def test_headers(self):
|
|
result = render("# H1\n## H2\n### H3")
|
|
assert "<h1>H1</h1>" in result
|
|
assert "<h2>H2</h2>" in result
|
|
assert "<h3>H3</h3>" in result
|
|
|
|
|
|
class TestLinks:
|
|
def test_basic_link(self):
|
|
result = render("[Google](https://google.com)")
|
|
assert '<a href="https://google.com">Google</a>' in result
|
|
|
|
def test_link_with_title(self):
|
|
result = render('[Home](https://example.com "Example Home")')
|
|
assert 'href="https://example.com"' in result
|
|
assert 'title="Example Home"' in result
|
|
|
|
def test_link_target_blank_is_preserved(self):
|
|
"""The markdown library doesn't add target, but if already in HTML..."""
|
|
# This tests that our sanitizer doesn't strip legitimate attrs
|
|
result = render("[link](https://a.com)")
|
|
assert "href" in result
|
|
|
|
|
|
class TestLists:
|
|
def test_unordered_list(self):
|
|
result = render("- Item 1\n- Item 2\n- Item 3")
|
|
assert "<ul>" in result
|
|
assert "<li>Item 1</li>" in result
|
|
assert "<li>Item 2</li>" in result
|
|
assert "<li>Item 3</li>" in result
|
|
|
|
def test_ordered_list(self):
|
|
result = render("1. First\n2. Second\n3. Third")
|
|
assert "<ol>" in result
|
|
assert "<li>First</li>" in result
|
|
assert "<li>Second</li>" in result
|
|
|
|
def test_nested_list(self):
|
|
result = render("- A\n - A1\n - A2\n- B")
|
|
assert result.count("<li>A</li>") == 1
|
|
assert result.count("<li>") >= 3 # A, A1, A2
|
|
|
|
|
|
class TestCodeBlocks:
|
|
def test_fenced_code_block(self):
|
|
result = render("```python\nprint('hello')\n```")
|
|
assert "<code>" in result or "<pre>" in result
|
|
|
|
def test_indented_code_block(self):
|
|
result = render(" code line 1\n code line 2")
|
|
assert "<code>" in result or "<pre>" in result
|
|
|
|
|
|
class TestBlockquotes:
|
|
def test_blockquote(self):
|
|
result = render("> This is a quote\n> Second line")
|
|
assert "<blockquote>" in result
|
|
assert "This is a quote" in result
|
|
|
|
|
|
class TestTables:
|
|
def test_table(self):
|
|
result = render("| A | B |\n|---|---|\n| 1 | 2 |")
|
|
assert "<table>" in result
|
|
assert "<th>A</th>" in result
|
|
assert "<td>1</td>" in result
|
|
|
|
|
|
class TestHorizontalRule:
|
|
def test_hr(self):
|
|
result = render("---")
|
|
assert "<hr" in result.lower()
|
|
|
|
|
|
class TestSanitization:
|
|
def test_script_tag_stripped(self):
|
|
"""<script> tags must be absent from rendered output."""
|
|
result = render("<script>alert('xss')</script>")
|
|
assert "<script>" not in result
|
|
assert "</script>" not in result
|
|
|
|
def test_event_handlers_stripped(self):
|
|
"""onerror/onclick handlers must be stripped even on allowed tags."""
|
|
result = render('<img src="x" onerror="alert(1)" />')
|
|
assert "onerror" not in result
|
|
assert 'src="x"' in result # legitimate attrs preserved
|
|
|
|
def test_javascript_url_stripped(self):
|
|
result = render("[click](javascript:alert(1))")
|
|
# The link text might appear, but the javascript: href must not
|
|
assert "javascript:" not in result.lower()
|
|
assert "click" in result
|
|
|
|
def test_regular_html_preserved_in_markdown(self):
|
|
"""Sanitization should NOT strip tags that come from markdown rendering."""
|
|
result = render("**bold** and *italic*")
|
|
assert "<strong>bold</strong>" in result
|
|
assert "<em>italic</em>" in result
|
|
|
|
|
|
class TestParagraphs:
|
|
def test_paragraph_wrapping(self):
|
|
result = render("Line 1.\n\nLine 2.")
|
|
assert result.count("<p>") >= 2
|
|
|
|
def test_single_line_break(self):
|
|
result = render("Line 1.\nLine 2.")
|
|
assert "Line 1." in result
|
|
assert "Line 2." in result
|
|
|
|
def test_multiple_paragraphs(self):
|
|
text = "Primo paragrafo.\n\nSecondo paragrafo.\n\nTerzo."
|
|
result = render(text)
|
|
assert "Primo paragrafo" in result
|
|
assert "Secondo paragrafo" in result
|
|
assert "Terzo" in result
|
|
|
|
|
|
# ── strip_markdown (TTS-friendly plain text) ──────────────────────
|
|
|
|
class TestStripMarkdown:
|
|
def test_empty_returns_empty(self):
|
|
assert strip_markdown("") == ""
|
|
assert strip_markdown(None) == ""
|
|
|
|
def test_plain_text_passes_through(self):
|
|
text = "Ciao mondo, oggi è una bella giornata."
|
|
assert strip_markdown(text) == text
|
|
|
|
def test_bold_double_asterisk(self):
|
|
result = strip_markdown("Questo è **importante** da sapere.")
|
|
assert result == "Questo è importante da sapere."
|
|
|
|
def test_bold_double_underscore(self):
|
|
result = strip_markdown("Questo è __importante__ da sapere.")
|
|
assert result == "Questo è importante da sapere."
|
|
|
|
def test_italic_single_asterisk(self):
|
|
result = strip_markdown("Una parola *italica* nel testo.")
|
|
assert result == "Una parola italica nel testo."
|
|
|
|
def test_italic_single_underscore(self):
|
|
result = strip_markdown("Una parola _italica_ nel testo.")
|
|
assert result == "Una parola italica nel testo."
|
|
|
|
def test_links_stripped(self):
|
|
result = strip_markdown(
|
|
"Visita [il nostro sito](https://example.com) per info."
|
|
)
|
|
assert result == "Visita il nostro sito per info."
|
|
|
|
def test_images_stripped(self):
|
|
result = strip_markdown(
|
|
"Ecco una foto: "
|
|
)
|
|
assert result == "Ecco una foto: logo"
|
|
|
|
def test_headings_stripped(self):
|
|
result = strip_markdown("# Titolo\n\nTesto del paragrafo.")
|
|
assert result == "Titolo\n\nTesto del paragrafo."
|
|
|
|
def test_code_blocks_stripped(self):
|
|
result = strip_markdown("```python\nprint('hello')\n```\n\nFine.")
|
|
assert "```" not in result
|
|
assert "print('hello')" in result
|
|
assert "Fine." in result
|
|
|
|
def test_inline_code_stripped(self):
|
|
result = strip_markdown("Usa il comando `pip install` per procedere.")
|
|
assert result == "Usa il comando pip install per procedere."
|
|
|
|
def test_blockquotes_stripped(self):
|
|
result = strip_markdown("> Questa è una citazione.\n> Seconda riga.")
|
|
assert "Questa è una citazione." in result
|
|
assert ">" not in result
|
|
|
|
def test_unordered_list_stripped(self):
|
|
result = strip_markdown("- Primo elemento\n- Secondo elemento\n- Terzo")
|
|
assert "Primo elemento" in result
|
|
assert "Secondo elemento" in result
|
|
assert "Terzo" in result
|
|
assert "-" not in result.split("Primo")[0]
|
|
|
|
def test_ordered_list_stripped(self):
|
|
result = strip_markdown("1. Primo\n2. Secondo\n3. Terzo")
|
|
assert "Primo" in result
|
|
assert "Secondo" in result
|
|
assert "Terzo" in result
|
|
|
|
def test_html_tags_stripped(self):
|
|
result = strip_markdown("Testo con <b>HTML</b> inline.")
|
|
assert result == "Testo con HTML inline."
|
|
|
|
def test_horizontal_rules_stripped(self):
|
|
result = strip_markdown("Prima sezione\n---\nSeconda sezione")
|
|
assert "---" not in result
|
|
assert "Prima sezione" in result
|
|
assert "Seconda sezione" in result
|
|
|
|
def test_multiple_blank_lines_collapsed(self):
|
|
result = strip_markdown("Riga 1\n\n\n\nRiga 2")
|
|
assert result == "Riga 1\n\nRiga 2"
|
|
|
|
def test_complex_mixed_markdown(self):
|
|
"""Realistic AI output with multiple markdown constructs."""
|
|
text = """## Risposta
|
|
|
|
**L'Odissea** è un poema epico attribuito a [Omero](https://it.wikipedia.org/wiki/Omero).
|
|
|
|
Ecco alcuni punti chiave:
|
|
- Composto intorno all'*VIII secolo a.C.*
|
|
- Narra il viaggio di **Ulisse**
|
|
- È diviso in `24 libri`
|
|
|
|
Per approfondire, vedi [Wikipedia](https://it.wikipedia.org)."""
|
|
result = strip_markdown(text)
|
|
# No markdown artifacts
|
|
assert "**" not in result
|
|
assert "*" not in result.split("VIII")[0] # no italic markers
|
|
assert "[" not in result
|
|
assert "](" not in result
|
|
assert "`" not in result
|
|
assert "##" not in result
|
|
assert "-" not in result.split("Composto")[0]
|
|
# Content preserved
|
|
assert "Odissea" in result
|
|
assert "Omero" in result
|
|
assert "VIII secolo a.C." in result
|
|
assert "Ulisse" in result
|
|
assert "24 libri" in result
|
|
assert "Wikipedia" in result
|