Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

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