""" 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)