testsuite fuer mailmerge
This commit is contained in:
@@ -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_<modul>.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 "<User>": "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_<modul>.py` (z.B. `test_preview_service.py` für `services/preview.py`)
|
||||||
|
- Test-Klassen: `TestVerhalten` oder `TestEntität` (PascalCase, ohne Unterstriche)
|
||||||
|
- Test-Funktionen: `test_<was_getestet_wird>` (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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
+5
-1
@@ -14,4 +14,8 @@ ignore = ["E501", "S101"]
|
|||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
DJANGO_SETTINGS_MODULE = "config.settings.dev"
|
DJANGO_SETTINGS_MODULE = "config.settings.dev"
|
||||||
python_files = ["test_*.py", "*_test.py", "tests.py"]
|
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)",
|
||||||
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user