test_report_export_service.py 7.29 KB
from __future__ import annotations

from datetime import datetime

import pytest

from services.application.report import ReportExportService, ReportExportUnavailableError
from services.application.report import (
    ReportArtifactNotFoundError,
    ReportJobNotFoundError,
    ReportJobNotReadyError,
)
from services.application.report.export_service import ReportExportValidationError


class _FakeTask:
    def __init__(self) -> None:
        self.markdown_file_path = ""
        self.markdown_file_relative_path = ""
        self.markdown_file_name = ""
        self.persisted = False

    def persist_snapshot(self) -> None:
        self.persisted = True


class _FakeMarkdownRenderer:
    def __init__(self, rendered: str = "# export") -> None:
        self.rendered = rendered
        self.calls: list[dict[str, object]] = []

    def render(self, document_ir, *, ir_file_path):
        self.calls.append({"document_ir": document_ir, "ir_file_path": ir_file_path})
        return self.rendered


class _FakePDFRenderer:
    def __init__(self, payload: bytes = b"%PDF-export") -> None:
        self.payload = payload
        self.calls: list[dict[str, object]] = []

    def render_to_bytes(self, document_ir, *, optimize_layout):
        self.calls.append({"document_ir": document_ir, "optimize_layout": optimize_layout})
        return self.payload


def _build_service(
    *,
    export_source_builder=None,
    markdown_renderer: _FakeMarkdownRenderer | None = None,
    pdf_renderer: _FakePDFRenderer | None = None,
    pdf_dependency_checker=None,
    safe_filename_builder=None,
) -> ReportExportService:
    return ReportExportService(
        export_source_builder=export_source_builder or (lambda _task_id: {}),
        markdown_renderer_factory=lambda: markdown_renderer or _FakeMarkdownRenderer(),
        pdf_renderer_factory=lambda: pdf_renderer or _FakePDFRenderer(),
        pdf_dependency_checker=pdf_dependency_checker or (lambda: (True, "ok")),
        output_dir_getter=lambda: "/virtual/output",
        safe_filename_builder=safe_filename_builder or (lambda text: text.replace(" ", "_")),
        now_getter=lambda: datetime(2024, 1, 2, 3, 4, 5),
    )


def test_export_markdown_writes_file_and_persists_task(monkeypatch):
    task = _FakeTask()
    source = {
        "task": {"task_id": "report-export"},
        "task_query": "museum report",
        "task_record": task,
        "ir_file_path": "/virtual/report.ir.json",
        "document_ir": {"metadata": {"topic": "Museum Topic"}},
    }
    renderer = _FakeMarkdownRenderer()
    writes: list[tuple[str, str, str | None]] = []

    monkeypatch.setattr(
        "services.application.report.export_service.Path.mkdir",
        lambda self, parents=False, exist_ok=False: None,
    )
    monkeypatch.setattr(
        "services.application.report.export_service.Path.write_text",
        lambda self, content, encoding=None: writes.append((str(self), content, encoding)) or len(content),
    )
    monkeypatch.setattr(
        "services.application.report.export_service.Path.resolve",
        lambda self: self,
    )

    service = _build_service(
        export_source_builder=lambda task_id: source if task_id == "report-export" else None,
        markdown_renderer=renderer,
    )

    descriptor = service.export_markdown("report-export")

    assert descriptor == {
        "file_path": "\\virtual\\output\\report_Museum_Topic_20240102_030405.md",
        "download_name": "report_Museum_Topic_20240102_030405.md",
        "mimetype": "text/markdown",
    }
    assert writes == [(descriptor["file_path"], "# export", "utf-8")]
    assert task.persisted is True
    assert task.markdown_file_name == descriptor["download_name"]
    assert task.markdown_file_path == descriptor["file_path"]
    assert renderer.calls == [
        {"document_ir": {"metadata": {"topic": "Museum Topic"}}, "ir_file_path": "/virtual/report.ir.json"}
    ]


def test_export_pdf_returns_response_descriptor():
    source = {
        "task": {"task_id": "report-export"},
        "task_query": "museum report",
        "task_record": _FakeTask(),
        "ir_file_path": "/virtual/report.ir.json",
        "document_ir": {"metadata": {"topic": "Museum Topic"}},
    }
    renderer = _FakePDFRenderer()

    service = _build_service(
        export_source_builder=lambda _task_id: source,
        pdf_renderer=renderer,
    )

    descriptor = service.export_pdf("report-export", optimize_layout=False)

    assert descriptor == {
        "content_bytes": b"%PDF-export",
        "download_name": "report_Museum_Topic_20240102_030405.pdf",
        "mimetype": "application/pdf",
    }
    assert renderer.calls == [
        {"document_ir": {"metadata": {"topic": "Museum Topic"}}, "optimize_layout": False}
    ]


def test_export_pdf_from_ir_payload_renders_pdf_and_normalizes_optimize():
    renderer = _FakePDFRenderer()
    service = _build_service(pdf_renderer=renderer)

    descriptor = service.export_pdf_from_ir_payload(
        {
            "document_ir": {"metadata": {"topic": "Museum Topic"}},
            "optimize": "false",
        }
    )

    assert descriptor == {
        "content_bytes": b"%PDF-export",
        "download_name": "report_Museum_Topic_20240102_030405.pdf",
        "mimetype": "application/pdf",
    }
    assert renderer.calls == [
        {"document_ir": {"metadata": {"topic": "Museum Topic"}}, "optimize_layout": False}
    ]


def test_export_pdf_from_ir_payload_defaults_optimize_to_true():
    renderer = _FakePDFRenderer()
    service = _build_service(pdf_renderer=renderer)

    descriptor = service.export_pdf_from_ir_payload({"document_ir": {"metadata": {"title": "Fallback Topic"}}})

    assert descriptor["download_name"] == "report_Fallback_Topic_20240102_030405.pdf"
    assert renderer.calls == [
        {"document_ir": {"metadata": {"title": "Fallback Topic"}}, "optimize_layout": True}
    ]


@pytest.mark.parametrize(
    ("payload", "message"),
    [
        (["not", "an", "object"], "请求体必须是JSON对象"),
        ({}, "缺少document_ir参数"),
        ({"document_ir": {}, "optimize": "sometimes"}, "optimize参数必须是布尔值"),
    ],
)
def test_export_pdf_from_ir_payload_validates_request_payload(payload, message):
    service = _build_service()

    with pytest.raises(ReportExportValidationError, match=message):
        service.export_pdf_from_ir_payload(payload)


def test_export_pdf_from_ir_payload_raises_unavailable_when_dependency_missing():
    service = _build_service(pdf_dependency_checker=lambda: (False, "missing pango"))

    with pytest.raises(ReportExportUnavailableError, match="PDF 导出功能不可用") as exc_info:
        service.export_pdf_from_ir_payload({"document_ir": {}})

    assert exc_info.value.details == {
        "details": '请查看根目录 README.md "源码启动"的第二步(PDF 导出依赖)了解安装方法',
        "help_url": "https://github.com/666ghj/BettaFish#2-安装-pdf-导出所需系统依赖可选",
        "system_message": "missing pango",
    }


@pytest.mark.parametrize(
    "exc_type",
    [ReportJobNotFoundError, ReportJobNotReadyError, ReportArtifactNotFoundError],
)
def test_export_service_propagates_query_side_errors(exc_type):
    service = _build_service(
        export_source_builder=lambda _task_id: (_ for _ in ()).throw(exc_type()),
    )

    with pytest.raises(exc_type):
        service.export_markdown("report-export")