test(q1): add test_q1_quarantine.py — 11 tests (1 actif, 10 xfail strict)

Squelette de tests TDD pour Q-1 quarantaine différentielle.

État au commit :
- test_happy_path_no_quarantine_created_if_no_failure  actif (passe)
- 10 tests en xfail strict, à dégeler au fur et à mesure :
  * B-3 préflight (2 tests)
  * Q-1 quarantine flow (3 tests)
  * B-1 metadata (2 tests)
  * B-2 logs (2 tests)
  * INDEX.md (1 test)

Validation : 74 passed, 10 xfailed sur tests/unit/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-02 10:45:00 +02:00
parent cf78bea910
commit c4883291d3

View File

@@ -0,0 +1,235 @@
"""
Tests squelette pour Q-1 — Quarantaine différentielle sur rédaction PDF.
État : SQUELETTE en mode xfail/skip — attend le pseudo-code final de Qwen
(`docs/coordination/inbox/for-dom/2026-05-28_qwen_pseudocode-Q1-quarantaine.md`)
et l'implémentation Dom pour devenir des tests verts.
Convention :
- @pytest.mark.xfail(strict=True) tant que l'API n'existe pas
- Une fois l'impl en place, retirer xfail et le test doit passer
- Test = spec exécutable du comportement attendu
Chaque test correspond à un comportement défini dans D-6 / D-10.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
import pytest
# === Fixtures ====================================================
@pytest.fixture
def tmp_output_dir(tmp_path: Path) -> Path:
"""Dossier de sortie temporaire pour un batch."""
out = tmp_path / "output"
out.mkdir()
return out
@pytest.fixture
def sample_pdf_ok(tmp_path: Path) -> Path:
"""PDF qui s'extrait et se rédige normalement.
À remplacer par un vrai PDF fixture du corpus tests/data/."""
p = tmp_path / "doc_ok.pdf"
p.write_bytes(b"%PDF-1.4\n%fake\n") # placeholder
return p
@pytest.fixture
def sample_pdf_empty_text(tmp_path: Path) -> Path:
"""PDF dont l'extraction de texte retourne (quasi)-rien.
Doit déclencher le pré-flight B-3."""
p = tmp_path / "doc_empty.pdf"
p.write_bytes(b"%PDF-1.4\n%empty\n")
return p
@pytest.fixture
def sample_pdf_redaction_fails(tmp_path: Path) -> Path:
"""PDF dont le texte est extractible mais où la rédaction PyMuPDF échoue.
Cas typique : PDF avec annotations corrompues."""
p = tmp_path / "doc_redact_fail.pdf"
p.write_bytes(b"%PDF-1.4\n%redact_fails\n")
return p
# === Tests B-3 : pré-flight texte vide ===========================
@pytest.mark.xfail(strict=True, reason="Q-1 pas encore implémenté")
def test_preflight_empty_text_goes_to_quarantine(sample_pdf_empty_text: Path, tmp_output_dir: Path) -> None:
"""B-3 — Un document dont l'extraction retourne moins de N caractères
doit être placé en quarantaine sans tentative de rédaction."""
from anonymizer_core_refactored_onnx import process_pdf # noqa: F401
# process_pdf(sample_pdf_empty_text, output_dir=tmp_output_dir, ...)
quarantine_dir = tmp_output_dir / "quarantaine"
assert quarantine_dir.exists(), "Le dossier quarantaine doit être créé"
assert (quarantine_dir / "doc_empty.reason.txt").exists()
assert not (tmp_output_dir / "doc_empty.pseudonymise.txt").exists()
assert not (tmp_output_dir / "doc_empty.redacted.pdf").exists()
@pytest.mark.xfail(strict=True, reason="Q-1 pas encore implémenté")
def test_preflight_reason_format(sample_pdf_empty_text: Path, tmp_output_dir: Path) -> None:
"""Le fichier .reason.txt doit contenir : type de problème, horodatage,
longueur du texte extrait, suggestions opérateur."""
from anonymizer_core_refactored_onnx import process_pdf # noqa: F401
# process_pdf(sample_pdf_empty_text, output_dir=tmp_output_dir, ...)
reason = (tmp_output_dir / "quarantaine" / "doc_empty.reason.txt").read_text()
assert "preflight_text_too_short" in reason
assert "extracted_chars" in reason
assert "processed_at" in reason
# === Tests Q-1 : quarantaine différentielle =====================
@pytest.mark.xfail(strict=True, reason="Q-1 pas encore implémenté")
def test_redaction_failure_text_still_outputs(sample_pdf_redaction_fails: Path, tmp_output_dir: Path) -> None:
"""Q-1 cas Q-PDF — Si la rédaction PDF échoue mais que l'anonymisation texte
réussit, alors :
- le .pseudonymise.txt sort normalement dans output_dir
- le PDF original (ou partiellement rédigé) va en quarantaine
- un flag pdf_redaction_failed est enregistré
"""
from anonymizer_core_refactored_onnx import process_pdf # noqa: F401
# process_pdf(sample_pdf_redaction_fails, output_dir=tmp_output_dir, ...)
assert (tmp_output_dir / "doc_redact_fail.pseudonymise.txt").exists()
assert (tmp_output_dir / "doc_redact_fail.audit.jsonl").exists()
assert not (tmp_output_dir / "doc_redact_fail.redacted.pdf").exists()
assert (tmp_output_dir / "quarantaine" / "doc_redact_fail.reason.txt").exists()
reason = (tmp_output_dir / "quarantaine" / "doc_redact_fail.reason.txt").read_text()
assert "pdf_redaction_failed" in reason
@pytest.mark.xfail(strict=True, reason="Q-1 pas encore implémenté")
def test_no_silent_failure_on_redaction(sample_pdf_redaction_fails: Path, tmp_output_dir: Path, caplog) -> None:
"""Q-1 — Toute exception sur la rédaction PDF DOIT être logguée (warning au minimum).
Pas de `except Exception: pass` silencieux."""
from anonymizer_core_refactored_onnx import process_pdf # noqa: F401
# process_pdf(sample_pdf_redaction_fails, output_dir=tmp_output_dir, ...)
warnings = [r for r in caplog.records if r.levelname == "WARNING"]
assert any("redaction" in r.message.lower() for r in warnings), \
"Une rédaction PDF qui échoue doit produire un log.warning"
@pytest.mark.xfail(strict=True, reason="Q-1 pas encore implémenté")
def test_rescan_detects_residual_pii_triggers_quarantine(tmp_output_dir: Path) -> None:
"""Q-1 cas Q-DOC — Si le rescan post-anonymisation détecte des PII résiduelles
au-dessus d'un seuil, le document complet va en quarantaine."""
# Construire un cas où le rescan détecte un nom oublié
# process_pdf(...)
quarantine_dir = tmp_output_dir / "quarantaine"
assert quarantine_dir.exists()
# Le doc n'est pas dans la sortie normale
assert len(list(tmp_output_dir.glob("*.pseudonymise.txt"))) == 0
# === Tests B-1 : métadonnées de sortie ==========================
@pytest.mark.xfail(strict=True, reason="B-1 pas encore implémenté")
def test_audit_jsonl_contains_metadata(sample_pdf_ok: Path, tmp_output_dir: Path) -> None:
"""B-1 — Le .audit.jsonl doit contenir une entrée de métadonnées avec :
app_version, commit_sha, processed_at, profile_applied."""
from anonymizer_core_refactored_onnx import process_pdf # noqa: F401
# process_pdf(sample_pdf_ok, output_dir=tmp_output_dir, ...)
audit_path = tmp_output_dir / "doc_ok.audit.jsonl"
assert audit_path.exists()
lines = audit_path.read_text().splitlines()
metadata_entry = None
for line in lines:
entry = json.loads(line)
if entry.get("type") == "metadata":
metadata_entry = entry
break
assert metadata_entry is not None, "Le .audit.jsonl doit contenir une entrée type=metadata"
assert "app_version" in metadata_entry
assert "commit_sha" in metadata_entry
assert "processed_at" in metadata_entry
assert "profile_applied" in metadata_entry
@pytest.mark.xfail(strict=True, reason="B-1 pas encore implémenté")
def test_pdf_output_has_xmp_metadata(sample_pdf_ok: Path, tmp_output_dir: Path) -> None:
"""B-1 — Le PDF rédigé doit contenir des métadonnées XMP avec :
/CreatorTool = "Pseudonymisation vX.Y", /Producer contenant le commit."""
import fitz # noqa: F401
from anonymizer_core_refactored_onnx import process_pdf # noqa: F401
# process_pdf(sample_pdf_ok, output_dir=tmp_output_dir, ...)
pdf_path = tmp_output_dir / "doc_ok.redacted.pdf"
doc = fitz.open(pdf_path)
metadata: dict[str, Any] = doc.metadata or {}
doc.close()
assert "Pseudonymisation" in metadata.get("creator", "")
assert metadata.get("producer", "") != ""
# === Tests B-2 : logs exportables ===============================
@pytest.mark.xfail(strict=True, reason="B-2 pas encore implémenté")
def test_per_document_log_file_created(sample_pdf_ok: Path, tmp_output_dir: Path) -> None:
"""B-2 — Chaque document traité doit produire un fichier <docname>.log
à côté du .audit.jsonl."""
from anonymizer_core_refactored_onnx import process_pdf # noqa: F401
# process_pdf(sample_pdf_ok, output_dir=tmp_output_dir, ...)
log_path = tmp_output_dir / "doc_ok.log"
assert log_path.exists()
content = log_path.read_text()
assert "extraction" in content.lower() or "process" in content.lower()
@pytest.mark.xfail(strict=True, reason="B-2 pas encore implémenté")
def test_errors_log_cumulative(tmp_output_dir: Path) -> None:
"""B-2 — Un fichier errors.log cumulatif doit être maintenu dans output_dir
pendant un batch."""
# batch_process([sample_pdf_ok, sample_pdf_redaction_fails], output_dir=tmp_output_dir)
errors_log = tmp_output_dir / "errors.log"
assert errors_log.exists()
# === Tests Q-1 : autonomie quarantaine (no UI) =================
@pytest.mark.xfail(strict=True, reason="Q-1 pas encore implémenté")
def test_quarantine_index_file_generated(tmp_output_dir: Path) -> None:
"""Q-1 (no-UI) — Un INDEX.md doit lister tous les docs en quarantaine
avec leur raison. Permet au bêta-testeur de comprendre sans GUI."""
# batch_process([sample_pdf_empty_text, sample_pdf_redaction_fails], output_dir=tmp_output_dir)
index = tmp_output_dir / "quarantaine" / "INDEX.md"
assert index.exists()
content = index.read_text()
assert "doc_empty" in content
assert "doc_redact_fail" in content
# === Tests de non-régression ====================================
def test_happy_path_no_quarantine_created_if_no_failure(sample_pdf_ok: Path, tmp_output_dir: Path) -> None:
"""Non-régression — Sur un document qui se traite normalement,
aucun dossier `quarantaine/` ne doit être créé (économise du bruit)."""
from anonymizer_core_refactored_onnx import process_pdf # noqa: F401
# process_pdf(sample_pdf_ok, output_dir=tmp_output_dir, ...)
assert not (tmp_output_dir / "quarantaine").exists() or \
len(list((tmp_output_dir / "quarantaine").iterdir())) == 0