174 lines
6.1 KiB
Python
174 lines
6.1 KiB
Python
|
|
"""
|
|||
|
|
Unit-Tests für mailmerge.services.preview.
|
|||
|
|
|
|||
|
|
LibreOffice wird gemockt – die Tests prüfen nur die Logik:
|
|||
|
|
CSV-Parsing, Header-Validierung, Fehlerpfade, Result-Aufbau.
|
|||
|
|
"""
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import io
|
|||
|
|
from pathlib import Path
|
|||
|
|
from unittest.mock import patch
|
|||
|
|
|
|||
|
|
import pytest
|
|||
|
|
|
|||
|
|
from mailmerge.services.preview import (
|
|||
|
|
PreviewError,
|
|||
|
|
PreviewResult,
|
|||
|
|
_read_first_row,
|
|||
|
|
build_preview,
|
|||
|
|
)
|
|||
|
|
from mailmerge.tests.conftest import (
|
|||
|
|
CSV_EXTRA_COLUMN,
|
|||
|
|
CSV_MISSING_COLUMN,
|
|||
|
|
CSV_NO_DATA,
|
|||
|
|
CSV_NO_HEADER,
|
|||
|
|
CSV_VALID,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# _read_first_row
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
class TestReadFirstRow:
|
|||
|
|
def test_reads_header_and_first_row(self):
|
|||
|
|
f = io.BytesIO(CSV_VALID.encode("utf-8"))
|
|||
|
|
columns, row = _read_first_row(f)
|
|||
|
|
|
|||
|
|
assert columns == ["anrede_brief", "nachname", "vorname", "ort"]
|
|||
|
|
assert row["nachname"] == "Huber"
|
|||
|
|
assert row["vorname"] == "Andrea"
|
|||
|
|
assert row["ort"] == "Eisenstadt"
|
|||
|
|
|
|||
|
|
def test_strips_utf8_bom(self):
|
|||
|
|
"""CSV mit BOM darf nicht zu '\\ufeffanrede_brief' als erstem Header führen."""
|
|||
|
|
f = io.BytesIO(("\ufeff" + CSV_VALID).encode("utf-8"))
|
|||
|
|
columns, _ = _read_first_row(f)
|
|||
|
|
assert columns[0] == "anrede_brief"
|
|||
|
|
|
|||
|
|
def test_accepts_path(self, tmp_path):
|
|||
|
|
p = tmp_path / "r.csv"
|
|||
|
|
p.write_text(CSV_VALID, encoding="utf-8")
|
|||
|
|
columns, row = _read_first_row(p)
|
|||
|
|
assert row["nachname"] == "Huber"
|
|||
|
|
assert "ort" in columns
|
|||
|
|
|
|||
|
|
def test_no_header_raises(self):
|
|||
|
|
f = io.BytesIO(CSV_NO_HEADER.encode("utf-8"))
|
|||
|
|
with pytest.raises(PreviewError, match="keine Header"):
|
|||
|
|
_read_first_row(f)
|
|||
|
|
|
|||
|
|
def test_no_data_row_raises(self):
|
|||
|
|
f = io.BytesIO(CSV_NO_DATA.encode("utf-8"))
|
|||
|
|
with pytest.raises(PreviewError, match="keine Datenzeile"):
|
|||
|
|
_read_first_row(f)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# build_preview – Header-Validierung
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
class TestBuildPreviewValidation:
|
|||
|
|
def test_missing_column_is_reported(self, docx_file_on_disk):
|
|||
|
|
csv_buf = io.BytesIO(CSV_MISSING_COLUMN.encode("utf-8"))
|
|||
|
|
with pytest.raises(PreviewError, match="fehlen aber als Spalte"):
|
|||
|
|
build_preview(docx_file_on_disk, csv_buf)
|
|||
|
|
|
|||
|
|
def test_missing_column_lists_specific_name(self, docx_file_on_disk):
|
|||
|
|
csv_buf = io.BytesIO(CSV_MISSING_COLUMN.encode("utf-8"))
|
|||
|
|
with pytest.raises(PreviewError) as excinfo:
|
|||
|
|
build_preview(docx_file_on_disk, csv_buf)
|
|||
|
|
assert "ort" in str(excinfo.value)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# build_preview – Happy Path (mit gemocktem LibreOffice)
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
FAKE_PDF_BYTES = b"%PDF-1.4 fake test pdf"
|
|||
|
|
|
|||
|
|
|
|||
|
|
@pytest.fixture
|
|||
|
|
def patched_libreoffice(monkeypatch, tmp_path):
|
|||
|
|
"""Ersetzt docx_to_pdf durch eine Funktion, die eine Dummy-PDF-Datei
|
|||
|
|
in das Out-Dir legt und deren Pfad zurückgibt."""
|
|||
|
|
def fake_docx_to_pdf(docx_path: Path, out_dir: Path) -> Path:
|
|||
|
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|||
|
|
pdf_path = out_dir / (docx_path.stem + ".pdf")
|
|||
|
|
pdf_path.write_bytes(FAKE_PDF_BYTES)
|
|||
|
|
return pdf_path
|
|||
|
|
|
|||
|
|
monkeypatch.setattr(
|
|||
|
|
"mailmerge.services.preview.docx_to_pdf", fake_docx_to_pdf
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestBuildPreviewHappyPath:
|
|||
|
|
def test_returns_preview_result(self, docx_file_on_disk, patched_libreoffice):
|
|||
|
|
csv_buf = io.BytesIO(CSV_VALID.encode("utf-8"))
|
|||
|
|
result = build_preview(docx_file_on_disk, csv_buf)
|
|||
|
|
|
|||
|
|
assert isinstance(result, PreviewResult)
|
|||
|
|
assert result.pdf_bytes == FAKE_PDF_BYTES
|
|||
|
|
|
|||
|
|
def test_first_row_is_used(self, docx_file_on_disk, patched_libreoffice):
|
|||
|
|
csv_buf = io.BytesIO(CSV_VALID.encode("utf-8"))
|
|||
|
|
result = build_preview(docx_file_on_disk, csv_buf)
|
|||
|
|
|
|||
|
|
assert result.used_row["nachname"] == "Huber"
|
|||
|
|
assert result.used_row["vorname"] == "Andrea"
|
|||
|
|
|
|||
|
|
def test_placeholders_are_listed(
|
|||
|
|
self, docx_file_on_disk, patched_libreoffice, template_placeholders
|
|||
|
|
):
|
|||
|
|
csv_buf = io.BytesIO(CSV_VALID.encode("utf-8"))
|
|||
|
|
result = build_preview(docx_file_on_disk, csv_buf)
|
|||
|
|
assert set(result.placeholders) == set(template_placeholders)
|
|||
|
|
|
|||
|
|
def test_extra_columns_are_reported(self, docx_file_on_disk, patched_libreoffice):
|
|||
|
|
csv_buf = io.BytesIO(CSV_EXTRA_COLUMN.encode("utf-8"))
|
|||
|
|
result = build_preview(docx_file_on_disk, csv_buf)
|
|||
|
|
|
|||
|
|
assert "personalnr" in result.extra_columns
|
|||
|
|
assert result.missing_columns == []
|
|||
|
|
|
|||
|
|
def test_calls_libreoffice_exactly_once(
|
|||
|
|
self, docx_file_on_disk, monkeypatch, tmp_path
|
|||
|
|
):
|
|||
|
|
"""Sicherstellen, dass auch wirklich nur EIN Render-Vorgang läuft."""
|
|||
|
|
calls = []
|
|||
|
|
|
|||
|
|
def fake_docx_to_pdf(docx_path: Path, out_dir: Path) -> Path:
|
|||
|
|
calls.append(docx_path)
|
|||
|
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|||
|
|
pdf_path = out_dir / (docx_path.stem + ".pdf")
|
|||
|
|
pdf_path.write_bytes(FAKE_PDF_BYTES)
|
|||
|
|
return pdf_path
|
|||
|
|
|
|||
|
|
monkeypatch.setattr(
|
|||
|
|
"mailmerge.services.preview.docx_to_pdf", fake_docx_to_pdf
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
csv_buf = io.BytesIO(CSV_VALID.encode("utf-8"))
|
|||
|
|
build_preview(docx_file_on_disk, csv_buf)
|
|||
|
|
assert len(calls) == 1
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# build_preview – LibreOffice-Fehler propagieren
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
class TestBuildPreviewLibreOfficeFailure:
|
|||
|
|
def test_runtime_error_bubbles_up(self, docx_file_on_disk, monkeypatch):
|
|||
|
|
def fake_docx_to_pdf(docx_path, out_dir):
|
|||
|
|
raise RuntimeError("LibreOffice ist abgestürzt")
|
|||
|
|
|
|||
|
|
monkeypatch.setattr(
|
|||
|
|
"mailmerge.services.preview.docx_to_pdf", fake_docx_to_pdf
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
csv_buf = io.BytesIO(CSV_VALID.encode("utf-8"))
|
|||
|
|
with pytest.raises(RuntimeError, match="LibreOffice"):
|
|||
|
|
build_preview(docx_file_on_disk, csv_buf)
|