From d61dbd46fd87b0a94bceaf74d9f966ca99782ffb Mon Sep 17 00:00:00 2001 From: Hans-Christian Payer Date: Fri, 22 May 2026 08:40:04 +0200 Subject: [PATCH] testsuite fuer mailmerge --- TEST.md | 638 ++++++++++++++++++ app/mailmerge/tests/__init__.py | 0 app/mailmerge/tests/conftest.py | 129 ++++ .../tests/test_preview_integration.py | 42 ++ app/mailmerge/tests/test_preview_service.py | 173 +++++ app/mailmerge/tests/test_preview_view.py | 206 ++++++ app/pyproject.toml | 6 +- 7 files changed, 1193 insertions(+), 1 deletion(-) create mode 100644 TEST.md create mode 100644 app/mailmerge/tests/__init__.py create mode 100644 app/mailmerge/tests/conftest.py create mode 100644 app/mailmerge/tests/test_preview_integration.py create mode 100644 app/mailmerge/tests/test_preview_service.py create mode 100644 app/mailmerge/tests/test_preview_view.py diff --git a/TEST.md b/TEST.md new file mode 100644 index 0000000..7c217e4 --- /dev/null +++ b/TEST.md @@ -0,0 +1,638 @@ +# Tests — Anleitung + +Test-Suite für die Serienbrief-Anwendung, basierend auf **pytest** + **pytest-django**. + +--- + +## Inhaltsverzeichnis + +1. [Überblick](#überblick) +2. [Schnellstart](#schnellstart) +3. [Test-Aufbau](#test-aufbau) +4. [Tests ausführen](#tests-ausführen) +5. [Coverage](#coverage) +6. [Eigene Tests schreiben](#eigene-tests-schreiben) +7. [Fixtures](#fixtures) +8. [Marker & Selektion](#marker--selektion) +9. [Debugging fehlschlagender Tests](#debugging-fehlschlagender-tests) +10. [Häufige Fehler](#häufige-fehler) +11. [CI-Integration](#ci-integration) +12. [Konventionen](#konventionen) + +--- + +## Überblick + +Die Suite ist in drei Schichten aufgebaut: + +| Schicht | Datei | Was wird getestet | LibreOffice | +|---|---|---|---| +| **Unit** | `test_preview_service.py` | Service-Logik (CSV-Parsing, Header-Validierung, Result-Aufbau) | gemockt | +| **View** | `test_preview_view.py` | HTTP-Layer (Auth, Form-Validation, Responses) | gemockt | +| **Integration** | `test_preview_integration.py` | End-to-End mit echtem `soffice` | echt | + +Ziel: **Unit + View** laufen in **2–3 Sekunden** und werden bei jedem Code-Change ausgeführt. Integration läuft separat (manuell oder im Pre-Merge-CI), weil LibreOffice ~3–5 s pro Test braucht. + +--- + +## Schnellstart + +```bash +cd ~/projekte/serienbrief + +# 1. Sicherstellen, dass der web-Container läuft +docker compose ps web + +# 2. Test-DB initial anlegen (nur beim allerersten Mal nötig) +docker compose exec web pytest --create-db mailmerge/tests/ -v + +# 3. Ab jetzt: schnelle Suite +docker compose exec web pytest mailmerge/tests/ -v +``` + +Erwartung: ~22 Tests, alle grün, Laufzeit < 5 s. + +--- + +## Test-Aufbau + +``` +app/mailmerge/tests/ +├── __init__.py +├── conftest.py # Geteilte Fixtures +├── test_preview_service.py # Unit-Tests für services/preview.py +├── test_preview_view.py # View-Tests via Django Test Client +└── test_preview_integration.py # End-to-End mit echtem LibreOffice +``` + +`conftest.py` liefert mehrere wiederverwendbare Fixtures (siehe [Fixtures](#fixtures)). + +Konfiguration in `app/pyproject.toml`: + +```toml +[tool.pytest.ini_options] +DJANGO_SETTINGS_MODULE = "config.settings.dev" +python_files = ["test_*.py", "*_test.py", "tests.py"] +addopts = "--reuse-db -ra -m 'not integration'" +markers = [ + "integration: Test benötigt echte externe Tools (z.B. LibreOffice)", +] +``` + +- `--reuse-db`: Die Test-DB wird zwischen Läufen wiederverwendet — spart 2–3 s pro Lauf +- `-ra`: Zusammenfassung am Ende inkl. Skipped/xFailed +- `-m 'not integration'`: Integrationstests standardmäßig **aus** + +--- + +## Tests ausführen + +Alle Befehle werden auf dem **Server** im `web`-Container ausgeführt. Working Directory im Container ist `/app`, lokal `~/projekte/serienbrief/app/`. + +### Standardlauf (schnell, mocked) + +```bash +docker compose exec web pytest mailmerge/tests/ +``` + +### Verbose mit Test-Namen + +```bash +docker compose exec web pytest mailmerge/tests/ -v +``` + +### Einzelne Datei + +```bash +docker compose exec web pytest mailmerge/tests/test_preview_service.py -v +``` + +### Einzelne Test-Klasse oder -Funktion + +```bash +# Eine ganze Klasse +docker compose exec web pytest mailmerge/tests/test_preview_view.py::TestPreviewHappyPath -v + +# Ein einzelner Test +docker compose exec web pytest mailmerge/tests/test_preview_view.py::TestPreviewHappyPath::test_returns_pdf_body -v +``` + +### Per Pattern-Match + +```bash +# Alle Tests, deren Name 'extra_column' enthält +docker compose exec web pytest mailmerge/tests/ -k extra_column -v +``` + +### Mit Stop bei erstem Fehler + +```bash +docker compose exec web pytest mailmerge/tests/ -x +``` + +### Mit detaillierter Traceback-Ausgabe + +```bash +docker compose exec web pytest mailmerge/tests/ -vv --tb=long +``` + +### Integration-Tests (echter LibreOffice) + +```bash +# Nur Integrationstests +docker compose exec web pytest mailmerge/tests/ -m integration -v + +# Alles (Unit + View + Integration) +docker compose exec web pytest mailmerge/tests/ -m '' -v +``` + +`-m ''` neutralisiert den Default-Filter `not integration`. + +### Test-DB neu aufbauen + +Wenn Modelländerungen seit dem letzten Lauf passiert sind, oder die DB inkonsistent erscheint: + +```bash +docker compose exec web pytest --create-db mailmerge/tests/ -v +``` + +### Lokal außerhalb des Containers (devcontainer / virtualenv) + +Falls du außerhalb von Docker testen willst (z.B. im VS Code Devcontainer): + +```bash +cd app +pytest mailmerge/tests/ -v +``` + +Voraussetzung: `requirements.txt` + `requirements-dev.txt` installiert, Postgres + Redis erreichbar, oder eine SQLite-Test-Override in `settings/dev.py`. + +--- + +## Coverage + +`coverage.py` ist nicht in `requirements-dev.txt` enthalten — bei Bedarf nachinstallieren: + +```bash +docker compose exec web pip install coverage +``` + +### Coverage messen + +```bash +docker compose exec web coverage run -m pytest mailmerge/tests/ +docker compose exec web coverage report -m +``` + +### Mit HTML-Report + +```bash +docker compose exec web coverage html -d /app/htmlcov +# Report dann im Browser öffnen: +# http://127.0.0.1:8000/static/htmlcov/index.html (wenn STATICFILES_DIRS angepasst) +# oder einfach per docker compose exec ansehen +``` + +### Coverage-Filter + +Nur die Mailmerge-App messen: + +```bash +docker compose exec web coverage run --source=mailmerge -m pytest mailmerge/tests/ +docker compose exec web coverage report -m +``` + +### Beispiel-Konfiguration (`.coveragerc` im `app/` Verzeichnis) + +```ini +[run] +source = mailmerge +omit = + */migrations/* + */tests/* + */management/* + +[report] +exclude_lines = + pragma: no cover + raise NotImplementedError + if __name__ == .__main__.: + if TYPE_CHECKING: + +show_missing = True +``` + +--- + +## Eigene Tests schreiben + +### Dateinamen + +`test_*.py` oder `*_test.py` — wird über `python_files` in `pyproject.toml` gesteuert. Empfehlung: konsistent `test_.py`. + +### Test-Funktion vs. Test-Klasse + +Tests können als **Funktionen** oder in **Klassen** organisiert werden. Klassen helfen, verwandte Tests zu gruppieren: + +```python +def test_einzelner_fall(): + assert 1 + 1 == 2 + + +class TestNutzerRegistrierung: + def test_email_pflicht(self, client): + ... + + def test_passwort_min_länge(self, client): + ... +``` + +Wichtig: pytest braucht **keine** `setUp`/`tearDown` und kein Erben von `TestCase`. Fixtures übernehmen das. + +### Datenbankzugriff + +Tests, die die DB nutzen, brauchen das `db`-Fixture oder müssen `@pytest.mark.django_db` annotiert sein: + +```python +import pytest + +@pytest.mark.django_db +def test_letter_template_creation(user): + from mailmerge.models import LetterTemplate + tpl = LetterTemplate.objects.create(name="Test", created_by=user) + assert tpl.id is not None +``` + +Bei Verwendung des `user`-Fixtures aus `conftest.py` wird `db` automatisch mitgezogen. + +### Mock-Pattern für externe Tools + +LibreOffice oder andere langsame externe Aufrufe **immer** in Unit-Tests mocken: + +```python +def test_etwas(monkeypatch, tmp_path): + def fake_libreoffice(docx_path, out_dir): + out_dir.mkdir(parents=True, exist_ok=True) + pdf = out_dir / "fake.pdf" + pdf.write_bytes(b"%PDF-1.4 fake") + return pdf + + monkeypatch.setattr( + "mailmerge.services.preview.docx_to_pdf", + fake_libreoffice, + ) + # ... Test ausführen ... +``` + +**Wichtig:** Monkey-Patch immer den **importierten** Namen — also dort, wo er **benutzt** wird, nicht wo er definiert ist. Beispiel: Wenn `views.py` macht `from .services.preview import build_preview`, dann patcht man `mailmerge.views.build_preview` und **nicht** `mailmerge.services.preview.build_preview`. + +### Beispiel: Neuer View-Test + +```python +import pytest +from django.urls import reverse + + +@pytest.mark.django_db +class TestMeineNeueView: + def test_login_required(self, client): + response = client.get(reverse("meine-view")) + assert response.status_code == 302 + + def test_user_kann_zugreifen(self, auth_client): + response = auth_client.get(reverse("meine-view")) + assert response.status_code == 200 + assert b"Erwarteter Inhalt" in response.content +``` + +--- + +## Fixtures + +Alle in `conftest.py` definierten Fixtures sind automatisch in allen Test-Dateien verfügbar. + +| Fixture | Typ | Was es liefert | +|---|---|---| +| `template_placeholders` | `list[str]` | Standard-Platzhalter `["anrede_brief", "nachname", "vorname", "ort"]` | +| `docx_bytes` | `bytes` | DOCX-Datei im Speicher mit den Platzhaltern | +| `docx_file_on_disk` | `Path` | DOCX als Datei in `tmp_path` | +| `csv_valid_bytes` | `bytes` | Valide CSV mit 2 Datenzeilen | +| `csv_uploaded_valid` | `SimpleUploadedFile` | Upload-fertige CSV für POST-Requests | +| `user` | `User` | Frischer Django-User in der Test-DB | +| `auth_client` | `Client` | Eingeloggter Django Test Client | +| `letter_template` | `LetterTemplate` | DB-Objekt + hochgeladene DOCX-Datei | + +Zusätzliche CSV-Konstanten in `conftest.py`: + +```python +from mailmerge.tests.conftest import ( + CSV_VALID, + CSV_MISSING_COLUMN, # 'ort' fehlt + CSV_EXTRA_COLUMN, # 'personalnr' überflüssig + CSV_NO_DATA, # nur Header + CSV_NO_HEADER, # leere Datei +) +``` + +### Eigene Fixtures hinzufügen + +Wenn eine Fixture **nur** für eine Datei gebraucht wird → direkt in der Test-Datei definieren. Wird sie **mehrfach** gebraucht → in `conftest.py` heben. + +```python +# In der Test-Datei selbst +@pytest.fixture +def besondere_csv(): + return "spalte1,spalte2\nwert1,wert2\n".encode("utf-8") +``` + +--- + +## Marker & Selektion + +### Built-in Marker + +- `@pytest.mark.django_db` — Test braucht DB-Zugriff +- `@pytest.mark.skip(reason="...")` — Test temporär überspringen +- `@pytest.mark.skipif(condition, reason="...")` — Bedingt überspringen +- `@pytest.mark.xfail` — Test darf fehlschlagen (z.B. bekannter Bug) +- `@pytest.mark.parametrize` — Test mit mehreren Datensätzen + +### Custom Marker + +Aktuell registriert (in `pyproject.toml`): + +- `@pytest.mark.integration` — Test braucht echte externe Tools (LibreOffice) + +Neue Marker müssen in `pyproject.toml` unter `markers` registriert werden, sonst gibt's eine Warnung. + +### Selektive Ausführung + +```bash +# Nur Tests mit Marker 'integration' +pytest -m integration + +# Tests ohne Marker 'integration' (default) +pytest -m 'not integration' + +# Mehrere Marker kombinieren +pytest -m 'slow and not integration' +``` + +### Parametrisierte Tests + +```python +import pytest + +@pytest.mark.parametrize("input,expected", [ + ("hallo", 5), + ("welt", 4), + ("", 0), +]) +def test_länge(input, expected): + assert len(input) == expected +``` + +Erzeugt drei Tests: `test_länge[hallo-5]`, `test_länge[welt-4]`, `test_länge[-0]`. + +--- + +## Debugging fehlschlagender Tests + +### Mehr Output + +```bash +# Output von print() etc. anzeigen (sonst von pytest unterdrückt) +docker compose exec web pytest mailmerge/tests/ -s + +# Vollständiger Traceback +docker compose exec web pytest mailmerge/tests/ --tb=long + +# Lokale Variablen im Traceback +docker compose exec web pytest mailmerge/tests/ --showlocals + +# Kombination +docker compose exec web pytest mailmerge/tests/ -vvs --tb=long --showlocals +``` + +### Bei erstem Fehler stoppen + +```bash +docker compose exec web pytest mailmerge/tests/ -x +``` + +### Letzten fehlgeschlagenen Test neu laufen lassen + +```bash +docker compose exec web pytest mailmerge/tests/ --lf # last failed +docker compose exec web pytest mailmerge/tests/ --ff # failed first, dann der Rest +``` + +### Mit pdb + +`breakpoint()` an die fragliche Stelle im Code setzen, dann: + +```bash +docker compose exec web pytest mailmerge/tests/ -s --pdb +``` + +Bei Fehler springt pytest in den Debugger. + +### Mit VS Code Debugger + +Die `.vscode/launch.json` hat bereits eine **Django: pytest** Konfiguration. Diese attached debugpy an den Container. Breakpoints im Test- oder App-Code setzen, F5 drücken. + +Alternativ einzelnen Test im VS Code via Test-Explorer starten — Pytest-Plugin erkennt die Suite automatisch. + +--- + +## Häufige Fehler + +### `django.db.utils.OperationalError: database "test_serienbrief" does not exist` + +Test-DB fehlt. Lösung: + +```bash +docker compose exec web pytest --create-db mailmerge/tests/ -v +``` + +### `RuntimeError: Database access not allowed` + +Test greift auf die DB zu, ist aber nicht als DB-Test markiert. Lösung: + +```python +@pytest.mark.django_db +def test_xyz(): ... +``` + +oder das `db`-Fixture injizieren. + +### `fixture 'xyz' not found` + +- Fixture-Datei nicht in `conftest.py`? +- Tippfehler im Fixture-Namen? +- `__init__.py` in `mailmerge/tests/` fehlt? + +Check: + +```bash +docker compose exec web ls -la mailmerge/tests/__init__.py +docker compose exec web pytest --fixtures mailmerge/tests/ | grep xyz +``` + +### `Cannot assign "": "MailMergeJob.created_by" must be a "User" instance` + +Eines der Modelle hat ein Feld mit eigenem User-Model, das von dem in `conftest.py` benutzten `get_user_model()` abweicht. Sollte mit `AUTH_USER_MODEL` automatisch zusammenpassen, sonst Fixture anpassen. + +### Integrationstests laufen nicht durch, weil LibreOffice fehlt + +``` +SKIPPED [1] LibreOffice (soffice) nicht im PATH +``` + +Das ist korrekt — die Skipif-Bedingung schützt vor falschen Failures. Im `web`-Container ist `soffice` immer da; auf anderen Hosts evtl. nicht. Erzwingen: + +```bash +docker compose exec web which soffice # sollte /usr/bin/soffice o.ä. zeigen +``` + +### `--reuse-db` führt zu kaputten Tests nach Modelländerung + +Wenn ein Modell geändert wurde, ist die wiederverwendete Test-DB veraltet: + +```bash +docker compose exec web pytest --create-db mailmerge/tests/ +``` + +--- + +## CI-Integration + +Aktuell nicht eingerichtet — Vorschlag für GitHub Actions oder GitLab CI: + +### Beispiel-Stage `test` für GitLab CI + +```yaml +test: + stage: test + image: docker:27 + services: + - docker:27-dind + variables: + DOCKER_HOST: tcp://docker:2375 + DOCKER_TLS_CERTDIR: "" + before_script: + - apk add --no-cache docker-compose + - cp .env.example .env + - openssl rand -hex 32 | tr -d '\n' > secrets/postgres_password.txt + script: + - docker compose build web + - docker compose up -d db redis + - docker compose run --rm web pytest --create-db mailmerge/tests/ -v + after_script: + - docker compose down -v +``` + +### Beispiel für GitHub Actions + +```yaml +name: Test + +on: [push, pull_request] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup environment + run: | + cp .env.example .env + openssl rand -hex 32 | tr -d '\n' > secrets/postgres_password.txt + - name: Build & test + run: | + docker compose build web + docker compose up -d db redis + docker compose run --rm web pytest --create-db mailmerge/tests/ -v +``` + +### Empfehlung: zweistufig + +1. **Pre-Merge / Push**: nur schnelle Suite (`pytest mailmerge/tests/`) +2. **Nightly oder Pre-Release**: alles inkl. Integration (`pytest -m '' mailmerge/tests/`) + +So bleibt das Feedback im Entwickleralltag schnell, aber LibreOffice-Regressionen werden regelmäßig geprüft. + +--- + +## Konventionen + +### Naming + +- Test-Dateien: `test_.py` (z.B. `test_preview_service.py` für `services/preview.py`) +- Test-Klassen: `TestVerhalten` oder `TestEntität` (PascalCase, ohne Unterstriche) +- Test-Funktionen: `test_` (snake_case, beschreibend) + +Gute Namen: +```python +def test_preview_pdf_response_has_inline_disposition(): ... +def test_csv_with_bom_strips_bom_from_first_column(): ... +``` + +Schlechte Namen: +```python +def test_preview1(): ... +def test_it_works(): ... +``` + +### Struktur eines Tests — Arrange / Act / Assert + +```python +def test_preview_returns_pdf_bytes(docx_file_on_disk, patched_libreoffice): + # Arrange: Eingaben aufbauen + csv_buf = io.BytesIO(CSV_VALID.encode("utf-8")) + + # Act: das System Under Test aufrufen + result = build_preview(docx_file_on_disk, csv_buf) + + # Assert: erwartetes Verhalten prüfen + assert isinstance(result, PreviewResult) + assert result.pdf_bytes.startswith(b"%PDF") or len(result.pdf_bytes) > 0 +``` + +### Eine Assertion pro Test (wenn sinnvoll) + +Statt: + +```python +def test_preview(): + result = ... + assert result.pdf_bytes + assert result.placeholders + assert result.extra_columns == [] +``` + +Lieber drei Tests mit klaren Namen — bei einem Fehler weißt du sofort, **welcher** Aspekt kaputt ist. Bei eng verwandten Assertions in einem Test ist das aber pragmatisch oft OK. + +### Was **nicht** getestet werden muss + +- Django-Framework-Verhalten (Authentication, ORM, etc.) — wird upstream getestet +- Triviale Property-Getter ohne Logik +- `__str__`-Methoden, es sei denn sie haben spezielle Format-Logik + +Was getestet werden **muss**: + +- Eigene Geschäftslogik (Validierung, Berechnung, Format-Konvertierung) +- Fehlerpfade (was passiert bei Input X?) +- HTTP-Layer-Contracts (Auth, Content-Types, Status-Codes) + +--- + +## Aktuell offene Punkte (Roadmap) + +- [ ] Tests für `mailmerge.tasks.run_mailmerge` (Celery-Worker-Pfad) +- [ ] Tests für `template_upload`-View +- [ ] Tests für `job_create` und `job_detail` (HTMX-Polling) +- [ ] Tests für `job_download` (X-Accel-Redirect-Header in Production) +- [ ] CI-Pipeline aufsetzen +- [ ] Coverage-Target setzen (z.B. ≥80% für `mailmerge/`) +- [ ] Mutation-Testing erwägen (`mutmut`) für kritische Service-Funktionen diff --git a/app/mailmerge/tests/__init__.py b/app/mailmerge/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/mailmerge/tests/conftest.py b/app/mailmerge/tests/conftest.py new file mode 100644 index 0000000..75e9f01 --- /dev/null +++ b/app/mailmerge/tests/conftest.py @@ -0,0 +1,129 @@ +""" +Gemeinsame Fixtures für die Mailmerge-Tests. + +Liefert ein minimales DOCX-Template, eine passende CSV und einen +authentifizierten Django-Testclient. +""" +from __future__ import annotations + +import io +from pathlib import Path + +import pytest +from django.contrib.auth import get_user_model +from django.core.files.uploadedfile import SimpleUploadedFile +from docx import Document + + +# --------------------------------------------------------------------------- +# DOCX-Helfer +# --------------------------------------------------------------------------- + +def _build_minimal_docx(placeholders: list[str]) -> bytes: + """Erzeugt ein DOCX im Speicher mit den übergebenen Jinja-Platzhaltern.""" + doc = Document() + doc.add_paragraph("Sehr {{ anrede_brief }} {{ nachname }},") + doc.add_paragraph(" ".join(f"{{{{ {p} }}}}" for p in placeholders)) + doc.add_paragraph("Mit freundlichen Grüßen") + buf = io.BytesIO() + doc.save(buf) + return buf.getvalue() + + +@pytest.fixture +def template_placeholders() -> list[str]: + return ["anrede_brief", "nachname", "vorname", "ort"] + + +@pytest.fixture +def docx_bytes(template_placeholders) -> bytes: + return _build_minimal_docx(template_placeholders) + + +@pytest.fixture +def docx_file_on_disk(tmp_path, docx_bytes) -> Path: + p = tmp_path / "template.docx" + p.write_bytes(docx_bytes) + return p + + +# --------------------------------------------------------------------------- +# CSV-Helfer +# --------------------------------------------------------------------------- + +CSV_VALID = ( + "anrede_brief,nachname,vorname,ort\n" + "geehrte Frau,Huber,Andrea,Eisenstadt\n" + "geehrter Herr,Müller,Bernhard,Neusiedl\n" +) + +CSV_MISSING_COLUMN = ( + "anrede_brief,nachname,vorname\n" # 'ort' fehlt + "geehrte Frau,Huber,Andrea\n" +) + +CSV_EXTRA_COLUMN = ( + "anrede_brief,nachname,vorname,ort,personalnr\n" + "geehrte Frau,Huber,Andrea,Eisenstadt,12345\n" +) + +CSV_NO_DATA = "anrede_brief,nachname,vorname,ort\n" + +CSV_NO_HEADER = "" + + +@pytest.fixture +def csv_valid_bytes() -> bytes: + return CSV_VALID.encode("utf-8") + + +@pytest.fixture +def csv_uploaded_valid(csv_valid_bytes) -> SimpleUploadedFile: + return SimpleUploadedFile( + "recipients.csv", csv_valid_bytes, content_type="text/csv" + ) + + +# --------------------------------------------------------------------------- +# Django-User / Client +# --------------------------------------------------------------------------- + +@pytest.fixture +def user(db): + User = get_user_model() + return User.objects.create_user( + username="testuser", + password="topsecret-123", + email="test@example.com", + ) + + +@pytest.fixture +def auth_client(client, user): + client.force_login(user) + return client + + +# --------------------------------------------------------------------------- +# LetterTemplate-Fixture (DB-persistent, mit echter DOCX-Datei) +# --------------------------------------------------------------------------- + +@pytest.fixture +def letter_template(db, user, docx_bytes, tmp_path, settings): + """Erzeugt ein LetterTemplate-DB-Objekt inkl. hochgeladener DOCX-Datei.""" + from mailmerge.models import LetterTemplate + + # MEDIA_ROOT auf tmp_path lenken, damit Testdateien isoliert bleiben + settings.MEDIA_ROOT = str(tmp_path / "media") + + tpl = LetterTemplate( + name="Testvorlage", + description="Pytest-Fixture", + created_by=user, + ) + tpl.file.save( + "test-template.docx", + SimpleUploadedFile("test-template.docx", docx_bytes), + save=True, + ) + return tpl diff --git a/app/mailmerge/tests/test_preview_integration.py b/app/mailmerge/tests/test_preview_integration.py new file mode 100644 index 0000000..fc1072c --- /dev/null +++ b/app/mailmerge/tests/test_preview_integration.py @@ -0,0 +1,42 @@ +""" +Integrationstest mit echtem LibreOffice. + +Per Default übersprungen — nur aktiv, wenn pytest explizit mit +'-m integration' aufgerufen wird ODER 'soffice' im PATH gefunden wird. + +Beispielaufruf im Container: + docker compose exec web pytest -m integration mailmerge/tests/test_preview_integration.py +""" +from __future__ import annotations + +import io +import shutil +from pathlib import Path + +import pytest + +from mailmerge.services.preview import build_preview +from mailmerge.tests.conftest import CSV_VALID + + +pytestmark = [ + pytest.mark.integration, + pytest.mark.skipif( + shutil.which("soffice") is None, + reason="LibreOffice (soffice) nicht im PATH – Integrationstest übersprungen", + ), +] + + +def test_real_libreoffice_produces_pdf(docx_file_on_disk): + """End-to-End mit echtem LibreOffice. Erwartet gültige PDF-Bytes.""" + csv_buf = io.BytesIO(CSV_VALID.encode("utf-8")) + result = build_preview(docx_file_on_disk, csv_buf) + + # PDF-Magic-Bytes + assert result.pdf_bytes.startswith(b"%PDF-"), ( + f"Kein PDF zurückbekommen, erste 8 Bytes: {result.pdf_bytes[:8]!r}" + ) + + # Eine Vorschau sollte mindestens ein paar KB groß sein – sonst stimmt was nicht + assert len(result.pdf_bytes) > 500 diff --git a/app/mailmerge/tests/test_preview_service.py b/app/mailmerge/tests/test_preview_service.py new file mode 100644 index 0000000..e3af25e --- /dev/null +++ b/app/mailmerge/tests/test_preview_service.py @@ -0,0 +1,173 @@ +""" +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) diff --git a/app/mailmerge/tests/test_preview_view.py b/app/mailmerge/tests/test_preview_view.py new file mode 100644 index 0000000..0d026bf --- /dev/null +++ b/app/mailmerge/tests/test_preview_view.py @@ -0,0 +1,206 @@ +""" +Tests für die job_preview-View. + +Hängt nicht am echten LibreOffice – build_preview wird auf Service-Ebene +gepatcht, sodass die Tests in <1 s laufen. +""" +from __future__ import annotations + +from pathlib import Path + +import pytest +from django.core.files.uploadedfile import SimpleUploadedFile +from django.urls import reverse + +from mailmerge.services.preview import PreviewError, PreviewResult + + +PREVIEW_URL_NAME = "job-preview" +FAKE_PDF = b"%PDF-1.4 fake preview\n" + + +# --------------------------------------------------------------------------- +# HTTP-Method / Auth +# --------------------------------------------------------------------------- + +class TestPreviewAccess: + def test_login_required(self, client, db): + url = reverse(PREVIEW_URL_NAME) + response = client.post(url, {}) + # Django leitet auf Login um (302) + assert response.status_code == 302 + assert "/login" in response["Location"].lower() or "login" in response["Location"].lower() + + def test_get_not_allowed(self, auth_client): + url = reverse(PREVIEW_URL_NAME) + response = auth_client.get(url) + assert response.status_code == 405 + + +# --------------------------------------------------------------------------- +# Form-Validierung +# --------------------------------------------------------------------------- + +class TestPreviewFormValidation: + def test_missing_files_returns_form(self, auth_client): + url = reverse(PREVIEW_URL_NAME) + response = auth_client.post(url, {}) + # Kein PDF, sondern Form-Template zurück + assert response.status_code == 200 + assert response["Content-Type"].startswith("text/html") + + def test_rejects_non_csv_extension( + self, auth_client, letter_template, docx_bytes + ): + url = reverse(PREVIEW_URL_NAME) + bogus = SimpleUploadedFile("file.txt", b"foo", content_type="text/plain") + response = auth_client.post(url, { + "template": str(letter_template.pk), + "recipients_csv": bogus, + }) + assert response.status_code == 200 + assert b"Nur .csv-Dateien" in response.content + + +# --------------------------------------------------------------------------- +# Happy Path +# --------------------------------------------------------------------------- + +@pytest.fixture +def patched_build_preview(monkeypatch): + """Ersetzt build_preview in der Views-Importebene durch einen Stub.""" + def fake_build_preview(template_path: Path, csv_file): + return PreviewResult( + pdf_bytes=FAKE_PDF, + used_row={"nachname": "Huber"}, + placeholders=["anrede_brief", "nachname", "vorname", "ort"], + csv_columns=["anrede_brief", "nachname", "vorname", "ort"], + missing_columns=[], + extra_columns=[], + ) + monkeypatch.setattr( + "mailmerge.views.build_preview", fake_build_preview + ) + return fake_build_preview + + +class TestPreviewHappyPath: + def test_returns_pdf_content_type( + self, auth_client, letter_template, csv_uploaded_valid, patched_build_preview + ): + url = reverse(PREVIEW_URL_NAME) + response = auth_client.post(url, { + "template": str(letter_template.pk), + "recipients_csv": csv_uploaded_valid, + }) + assert response.status_code == 200 + assert response["Content-Type"] == "application/pdf" + + def test_returns_pdf_body( + self, auth_client, letter_template, csv_uploaded_valid, patched_build_preview + ): + url = reverse(PREVIEW_URL_NAME) + response = auth_client.post(url, { + "template": str(letter_template.pk), + "recipients_csv": csv_uploaded_valid, + }) + assert response.content == FAKE_PDF + + def test_content_disposition_inline( + self, auth_client, letter_template, csv_uploaded_valid, patched_build_preview + ): + url = reverse(PREVIEW_URL_NAME) + response = auth_client.post(url, { + "template": str(letter_template.pk), + "recipients_csv": csv_uploaded_valid, + }) + assert response["Content-Disposition"].startswith("inline") + + def test_placeholders_header_set( + self, auth_client, letter_template, csv_uploaded_valid, patched_build_preview + ): + url = reverse(PREVIEW_URL_NAME) + response = auth_client.post(url, { + "template": str(letter_template.pk), + "recipients_csv": csv_uploaded_valid, + }) + assert "X-Preview-Placeholders" in response + assert "nachname" in response["X-Preview-Placeholders"] + + +# --------------------------------------------------------------------------- +# Extra-Spalten als Hinweis-Header +# --------------------------------------------------------------------------- + +class TestPreviewExtraColumnsHeader: + @pytest.fixture + def patched_with_extras(self, monkeypatch): + def fake_build_preview(template_path, csv_file): + return PreviewResult( + pdf_bytes=FAKE_PDF, + used_row={"nachname": "Huber"}, + placeholders=["nachname"], + csv_columns=["nachname", "personalnr"], + missing_columns=[], + extra_columns=["personalnr"], + ) + monkeypatch.setattr( + "mailmerge.views.build_preview", fake_build_preview + ) + + def test_extra_columns_header_contains_value( + self, auth_client, letter_template, csv_uploaded_valid, patched_with_extras + ): + url = reverse(PREVIEW_URL_NAME) + response = auth_client.post(url, { + "template": str(letter_template.pk), + "recipients_csv": csv_uploaded_valid, + }) + assert response["X-Preview-Extra-Columns"] == "personalnr" + + +# --------------------------------------------------------------------------- +# Fehlerpfade: PreviewError und unerwartete Exceptions +# --------------------------------------------------------------------------- + +class TestPreviewErrorHandling: + def test_preview_error_renders_form_with_message( + self, auth_client, letter_template, csv_uploaded_valid, monkeypatch + ): + def fake_build_preview(template_path, csv_file): + raise PreviewError("Spalte 'ort' fehlt") + + monkeypatch.setattr( + "mailmerge.views.build_preview", fake_build_preview + ) + + url = reverse(PREVIEW_URL_NAME) + response = auth_client.post(url, { + "template": str(letter_template.pk), + "recipients_csv": csv_uploaded_valid, + }) + assert response.status_code == 200 + assert response["Content-Type"].startswith("text/html") + # Django escaped Apostrophe in Templates ("'" -> "'") + body = response.content.decode("utf-8") + assert "Spalte" in body and "ort" in body and "fehlt" in body + + def test_unexpected_exception_renders_form( + self, auth_client, letter_template, csv_uploaded_valid, monkeypatch + ): + def fake_build_preview(template_path, csv_file): + raise RuntimeError("LibreOffice timeout") + + monkeypatch.setattr( + "mailmerge.views.build_preview", fake_build_preview + ) + + url = reverse(PREVIEW_URL_NAME) + response = auth_client.post(url, { + "template": str(letter_template.pk), + "recipients_csv": csv_uploaded_valid, + }) + assert response.status_code == 200 + assert response["Content-Type"].startswith("text/html") + assert b"Vorschau konnte nicht erstellt werden" in response.content + assert b"LibreOffice timeout" in response.content diff --git a/app/pyproject.toml b/app/pyproject.toml index 87a87b3..c643d60 100644 --- a/app/pyproject.toml +++ b/app/pyproject.toml @@ -14,4 +14,8 @@ ignore = ["E501", "S101"] [tool.pytest.ini_options] DJANGO_SETTINGS_MODULE = "config.settings.dev" python_files = ["test_*.py", "*_test.py", "tests.py"] -addopts = "--reuse-db -ra" +# integration-Tests standardmäßig ausschließen, nur via '-m integration' aktiv +addopts = "--reuse-db -ra -m 'not integration'" +markers = [ + "integration: Test benötigt echte externe Tools (z.B. LibreOffice)", +]