7a0da7932b
Backwards Compatibility / Verify Encryption Constants (push) Waiting to run
Backwards Compatibility / PyPI Version Compatibility (push) Waiting to run
Backwards Compatibility / Database Migration Tests (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
CodeQL Advanced / Analyze (python) (push) Waiting to run
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Blocked by required conditions
Docker Tests (Consolidated) / detect-changes (push) Waiting to run
Docker Tests (Consolidated) / Build Test Image (push) Waiting to run
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Blocked by required conditions
Docker Tests (Consolidated) / Accessibility Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / LLM Unit Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / LLM Example Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / Production Image Smoke Test (push) Blocked by required conditions
Docker Tests (Consolidated) / Infrastructure Tests (push) Blocked by required conditions
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Waiting to run
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
399 lines
14 KiB
Python
399 lines
14 KiB
Python
"""
|
|
Comprehensive tests for PDFService.
|
|
Tests PDF generation, markdown conversion, CSS handling, and metadata.
|
|
"""
|
|
|
|
import io
|
|
import subprocess
|
|
|
|
import pytest
|
|
from unittest.mock import patch
|
|
|
|
|
|
def _host_has_cjk_fonts() -> bool:
|
|
"""True if fontconfig reports any CJK-capable family on the host.
|
|
|
|
Used to gate the strong glyph-embedding assertion in
|
|
test_handles_cjk_content_embeds_glyphs: Docker CI (where this PR
|
|
installs fonts-noto-cjk) and properly-configured dev hosts run the
|
|
assertion; bare pip/macOS/Windows installs without Noto CJK skip it.
|
|
"""
|
|
try:
|
|
result = subprocess.run(
|
|
["fc-list", ":lang=zh"],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=5,
|
|
)
|
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
return False
|
|
return bool(result.stdout.strip())
|
|
|
|
|
|
class TestPDFServiceInit:
|
|
"""Tests for PDFService initialization."""
|
|
|
|
def test_init_creates_minimal_css(self):
|
|
"""Test that initialization creates minimal CSS."""
|
|
from local_deep_research.web.services.pdf_service import PDFService
|
|
|
|
service = PDFService()
|
|
|
|
assert service.minimal_css is not None
|
|
|
|
def test_minimal_css_includes_page_settings(self):
|
|
"""Test that minimal CSS includes @page settings."""
|
|
from local_deep_research.web.services.pdf_service import PDFService
|
|
|
|
service = PDFService()
|
|
|
|
# The CSS object wraps the string, so we check it was created
|
|
assert service.minimal_css is not None
|
|
|
|
|
|
class TestPDFServiceMarkdownToHtml:
|
|
"""Tests for _markdown_to_html method."""
|
|
|
|
@pytest.fixture
|
|
def service(self):
|
|
"""Create PDFService instance."""
|
|
from local_deep_research.web.services.pdf_service import PDFService
|
|
|
|
return PDFService()
|
|
|
|
def test_converts_basic_markdown(self, service, simple_markdown):
|
|
"""Test conversion of basic markdown to HTML."""
|
|
html = service._markdown_to_html(simple_markdown)
|
|
|
|
# Markdown adds id attribute to headings
|
|
assert "<h1" in html and "Hello World</h1>" in html
|
|
assert "<p>This is a test.</p>" in html
|
|
|
|
def test_includes_doctype_and_structure(self, service, simple_markdown):
|
|
"""Test that HTML includes proper structure."""
|
|
html = service._markdown_to_html(simple_markdown)
|
|
|
|
assert "<!DOCTYPE html>" in html
|
|
assert "<html>" in html
|
|
assert "</html>" in html
|
|
assert "<head>" in html
|
|
assert "</head>" in html
|
|
assert "<body>" in html
|
|
assert "</body>" in html
|
|
|
|
def test_includes_charset_meta(self, service, simple_markdown):
|
|
"""Test that HTML includes UTF-8 charset."""
|
|
html = service._markdown_to_html(simple_markdown)
|
|
|
|
assert '<meta charset="utf-8">' in html
|
|
|
|
def test_includes_title_when_provided(self, service, simple_markdown):
|
|
"""Test that title is included when provided."""
|
|
html = service._markdown_to_html(simple_markdown, title="Test Title")
|
|
|
|
assert "<title>Test Title</title>" in html
|
|
|
|
def test_excludes_title_when_not_provided(self, service, simple_markdown):
|
|
"""Test that no title tag when title not provided."""
|
|
html = service._markdown_to_html(simple_markdown)
|
|
|
|
assert "<title>" not in html
|
|
|
|
def test_includes_metadata_as_meta_tags(
|
|
self, service, simple_markdown, sample_metadata
|
|
):
|
|
"""Test that metadata is included as meta tags."""
|
|
html = service._markdown_to_html(
|
|
simple_markdown, metadata=sample_metadata
|
|
)
|
|
|
|
assert '<meta name="author" content="Test Author">' in html
|
|
assert '<meta name="date" content="2024-01-15">' in html
|
|
assert '<meta name="subject" content="Test Subject">' in html
|
|
|
|
def test_includes_ldr_attribution_footer(self, service, simple_markdown):
|
|
"""Test that LDR attribution footer is included."""
|
|
html = service._markdown_to_html(simple_markdown)
|
|
|
|
assert "Generated by" in html
|
|
assert "Local Deep Research" in html
|
|
|
|
def test_converts_tables(self, service, sample_markdown):
|
|
"""Test that markdown tables are converted."""
|
|
html = service._markdown_to_html(sample_markdown)
|
|
|
|
assert "<table>" in html
|
|
assert "<th>" in html
|
|
assert "Column 1" in html
|
|
|
|
def test_converts_code_blocks(self, service, sample_markdown):
|
|
"""Test that fenced code blocks are converted."""
|
|
html = service._markdown_to_html(sample_markdown)
|
|
|
|
assert "<code" in html or "<pre>" in html
|
|
assert "def example():" in html
|
|
|
|
def test_converts_lists(self, service, sample_markdown):
|
|
"""Test that lists are converted."""
|
|
html = service._markdown_to_html(sample_markdown)
|
|
|
|
assert "<li>" in html
|
|
assert "Item 1" in html
|
|
|
|
def test_converts_bold_and_italic(self, service, sample_markdown):
|
|
"""Test that bold and italic text are converted."""
|
|
html = service._markdown_to_html(sample_markdown)
|
|
|
|
assert "<strong>bold</strong>" in html
|
|
assert "<em>italic</em>" in html
|
|
|
|
def test_handles_special_characters(
|
|
self, service, markdown_with_special_chars
|
|
):
|
|
"""Test handling of special characters."""
|
|
html = service._markdown_to_html(markdown_with_special_chars)
|
|
|
|
# Unicode should pass through
|
|
assert "café" in html
|
|
assert "naïve" in html
|
|
assert "日本語" in html
|
|
|
|
|
|
class TestPDFServiceMarkdownToPdf:
|
|
"""Tests for markdown_to_pdf method."""
|
|
|
|
@pytest.fixture
|
|
def service(self):
|
|
"""Create PDFService instance."""
|
|
from local_deep_research.web.services.pdf_service import PDFService
|
|
|
|
return PDFService()
|
|
|
|
def test_returns_bytes(self, service, simple_markdown):
|
|
"""Test that markdown_to_pdf returns bytes."""
|
|
result = service.markdown_to_pdf(simple_markdown)
|
|
|
|
assert isinstance(result, bytes)
|
|
|
|
def test_returns_valid_pdf(self, service, simple_markdown):
|
|
"""Test that result is a valid PDF (starts with %PDF)."""
|
|
result = service.markdown_to_pdf(simple_markdown)
|
|
|
|
assert result.startswith(b"%PDF")
|
|
|
|
def test_pdf_has_reasonable_size(self, service, simple_markdown):
|
|
"""Test that generated PDF has a reasonable size."""
|
|
result = service.markdown_to_pdf(simple_markdown)
|
|
|
|
# A simple PDF should be at least a few KB
|
|
assert len(result) > 1000
|
|
|
|
def test_applies_title(self, service, simple_markdown):
|
|
"""Test that title is applied to PDF."""
|
|
result = service.markdown_to_pdf(simple_markdown, title="My Title")
|
|
|
|
# PDF was generated successfully
|
|
assert result.startswith(b"%PDF")
|
|
assert len(result) > 0
|
|
|
|
def test_applies_metadata(self, service, simple_markdown, sample_metadata):
|
|
"""Test that metadata is applied to PDF."""
|
|
result = service.markdown_to_pdf(
|
|
simple_markdown, metadata=sample_metadata
|
|
)
|
|
|
|
assert result.startswith(b"%PDF")
|
|
|
|
def test_applies_custom_css(self, service, simple_markdown, custom_css):
|
|
"""Test that custom CSS is applied."""
|
|
result = service.markdown_to_pdf(simple_markdown, custom_css=custom_css)
|
|
|
|
assert result.startswith(b"%PDF")
|
|
|
|
def test_uses_minimal_css_when_no_custom_css(
|
|
self, service, simple_markdown
|
|
):
|
|
"""Test that minimal CSS is used by default."""
|
|
result = service.markdown_to_pdf(simple_markdown)
|
|
|
|
assert result.startswith(b"%PDF")
|
|
|
|
def test_handles_empty_markdown(self, service):
|
|
"""Test handling of empty markdown."""
|
|
result = service.markdown_to_pdf("")
|
|
|
|
assert result.startswith(b"%PDF")
|
|
|
|
def test_handles_large_markdown(self, service):
|
|
"""Test handling of large markdown content."""
|
|
# Create a large markdown document
|
|
large_content = "# Large Document\n\n"
|
|
large_content += ("This is a paragraph. " * 100 + "\n\n") * 50
|
|
|
|
result = service.markdown_to_pdf(large_content)
|
|
|
|
assert result.startswith(b"%PDF")
|
|
# Large content should produce larger PDF
|
|
assert len(result) > 10000
|
|
|
|
def test_handles_markdown_with_all_features(self, service, sample_markdown):
|
|
"""Test handling of markdown with tables, code, lists."""
|
|
result = service.markdown_to_pdf(sample_markdown)
|
|
|
|
assert result.startswith(b"%PDF")
|
|
|
|
def test_handles_cjk_content(self, service):
|
|
"""Regression guard for #4055 — CJK text must not crash export.
|
|
|
|
Glyph visibility depends on the host having CJK fonts installed
|
|
(e.g. fonts-noto-cjk). This test pins the no-crash contract; the
|
|
glyph-embedding contract is checked separately in
|
|
test_handles_cjk_content_embeds_glyphs.
|
|
"""
|
|
cjk_markdown = (
|
|
"# 中文标题\n\n这是一个测试。\n\n"
|
|
"## 日本語の見出し\n\nテスト本文。\n\n"
|
|
"## 한국어 제목\n\n본문 테스트.\n"
|
|
)
|
|
|
|
result = service.markdown_to_pdf(cjk_markdown)
|
|
|
|
assert result.startswith(b"%PDF")
|
|
assert len(result) > 1000
|
|
|
|
@pytest.mark.skipif(
|
|
not _host_has_cjk_fonts(),
|
|
reason="no CJK fonts on host; install fonts-noto-cjk to run",
|
|
)
|
|
def test_handles_cjk_content_embeds_glyphs(self, service):
|
|
"""End-to-end glyph check for #4055.
|
|
|
|
Round-trips CJK text through markdown → PDF → text extraction.
|
|
If no CJK-capable font is available to WeasyPrint, glyphs are
|
|
dropped and pypdf's extract_text returns the rendered text
|
|
without them.
|
|
|
|
What this catches: removal of fonts-noto-cjk from the runtime
|
|
Docker image (the load-bearing half of the fix) — pytest-tests
|
|
runs inside that image, so the test fails when fonts are gone.
|
|
|
|
What this does NOT catch: removal of the CSS CJK fallback list.
|
|
On Linux, fontconfig auto-substitutes a glyph-bearing family
|
|
even for a Latin-only CSS stack, so the assertion still passes.
|
|
The CSS fallbacks matter on Windows/macOS, which CI doesn't run.
|
|
"""
|
|
import pypdf
|
|
|
|
cjk_markdown = (
|
|
"# 中文标题\n\n这是一个测试。\n\n"
|
|
"## 日本語の見出し\n\nテスト本文。\n\n"
|
|
"## 한국어 제목\n\n본문 테스트.\n"
|
|
)
|
|
|
|
pdf_bytes = service.markdown_to_pdf(cjk_markdown)
|
|
reader = pypdf.PdfReader(io.BytesIO(pdf_bytes))
|
|
extracted = "".join(page.extract_text() for page in reader.pages)
|
|
|
|
for phrase in (
|
|
"中文标题",
|
|
"这是一个测试",
|
|
"日本語",
|
|
"テスト",
|
|
"한국어",
|
|
"본문",
|
|
):
|
|
assert phrase in extracted, (
|
|
f"CJK glyphs missing from PDF: {phrase!r} not found in "
|
|
f"extracted text — WeasyPrint could not match a glyph "
|
|
f"(check that fonts-noto-cjk is installed on the host)"
|
|
)
|
|
|
|
def test_logs_pdf_size(self, service, simple_markdown):
|
|
"""Test that PDF size is logged."""
|
|
with patch(
|
|
"local_deep_research.web.services.pdf_service.logger"
|
|
) as mock_logger:
|
|
service.markdown_to_pdf(simple_markdown)
|
|
|
|
mock_logger.info.assert_called_once()
|
|
call_args = mock_logger.info.call_args[0][0]
|
|
assert "Generated PDF" in call_args
|
|
assert "bytes" in call_args
|
|
|
|
|
|
class TestPDFServiceErrorHandling:
|
|
"""Tests for error handling in PDFService."""
|
|
|
|
@pytest.fixture
|
|
def service(self):
|
|
"""Create PDFService instance."""
|
|
from local_deep_research.web.services.pdf_service import PDFService
|
|
|
|
return PDFService()
|
|
|
|
def test_raises_exception_on_html_error(self, service):
|
|
"""Test that exceptions are raised on HTML conversion errors."""
|
|
with patch.object(
|
|
service, "_markdown_to_html", side_effect=Exception("HTML error")
|
|
):
|
|
with pytest.raises(Exception) as exc_info:
|
|
service.markdown_to_pdf("test")
|
|
|
|
assert "HTML error" in str(exc_info.value)
|
|
|
|
def test_logs_exception_on_error(self, service):
|
|
"""Test that exceptions are logged."""
|
|
with patch.object(
|
|
service, "_markdown_to_html", side_effect=Exception("Test error")
|
|
):
|
|
with patch(
|
|
"local_deep_research.web.services.pdf_service.logger"
|
|
) as mock_logger:
|
|
with pytest.raises(Exception):
|
|
service.markdown_to_pdf("test")
|
|
|
|
mock_logger.exception.assert_called_once()
|
|
|
|
|
|
class TestPDFServiceSingleton:
|
|
"""Tests for get_pdf_service singleton."""
|
|
|
|
def test_get_pdf_service_returns_instance(self):
|
|
"""Test that get_pdf_service returns a PDFService instance."""
|
|
from local_deep_research.web.services.pdf_service import (
|
|
get_pdf_service,
|
|
PDFService,
|
|
)
|
|
|
|
service = get_pdf_service()
|
|
|
|
assert isinstance(service, PDFService)
|
|
|
|
def test_get_pdf_service_returns_same_instance(self):
|
|
"""Test that get_pdf_service returns the same instance."""
|
|
from local_deep_research.web.services.pdf_service import (
|
|
get_pdf_service,
|
|
)
|
|
|
|
service1 = get_pdf_service()
|
|
service2 = get_pdf_service()
|
|
|
|
assert service1 is service2
|
|
|
|
def test_singleton_can_be_reset(self):
|
|
"""Test that singleton can be reset for testing."""
|
|
import local_deep_research.web.services.pdf_service as pdf_module
|
|
|
|
# Reset singleton
|
|
pdf_module._pdf_service = None
|
|
|
|
service1 = pdf_module.get_pdf_service()
|
|
|
|
# Reset again
|
|
pdf_module._pdf_service = None
|
|
|
|
service2 = pdf_module.get_pdf_service()
|
|
|
|
# Different instances after reset
|
|
assert service1 is not service2
|