Files
benevoles/backend/tests/test_import_service.py
T
AdamThomasandClaude Sonnet 5 897a76ca6d Bénévole : l'email n'est plus unique (couple, famille)
Plusieurs bénévoles peuvent légitimement partager une même adresse email —
l'architecture l'interdisait : l'import ignorait silencieusement le second
(« duplicate_email_in_file »), et l'ajout manuel renvoyait un 409. Les deux
sont corrigés.

- Migration 0009 : retrait de la contrainte d'unicité (edition_id, email) sur
  `volunteer` (l'index sur `email` seul est conservé).
- Import (`app/importing/service.py`) : l'upsert se fait désormais sur
  (email, nom + prénom), pas sur l'email seul. Un même email revu avec un nom
  différent dans le fichier crée un second bénévole (nouveau code
  `import.row.shared_email`, WARNING) au lieu d'être ignoré ;
  `duplicate_email_in_file` ne se déclenche plus que sur une ligne
  littéralement répétée (même email ET même nom).
- API (`POST`/`PATCH /volunteers`) : retrait du rejet 409
  `volunteer.duplicate_email`, devenu sans objet (le mock ne l'a d'ailleurs
  jamais appliqué à la création manuelle).
- Mock frontend (`mock-client.ts`) : même correction de l'upsert par import,
  pour rester cohérent avec le backend.
- CLAUDE.md à jour (modèle de domaine, règles d'import, journal).

Contrepartie assumée (documentée) : un email re-importé avec un nom
légèrement différent (faute de frappe corrigée) crée désormais un bénévole
de plus signalé, plutôt que de renommer silencieusement l'existant — même
philosophie que la détection sans email, qui ne fusionne jamais rien
automatiquement.

Vérifié : 179 tests backend, ruff, alembic check ; typecheck/lint/build/smoke
frontend ; reseed de démonstration.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
2026-09-15 19:38:28 +02:00

227 lines
7.1 KiB
Python

from sqlmodel import select
from app.importing.parser import ParsedTable
from app.importing.schemas import ColumnMapping, ImportMapping
from app.importing.service import run_import, suggest_mapping, validate_mapping
from app.models import Volunteer
HEADERS = ["Prénom", "Nom", "Email", "Téléphone", "Remarque"]
def _mapping(dry_run: bool = False) -> ImportMapping:
return ImportMapping(
dry_run=dry_run,
columns=ColumnMapping(
first_name="Prénom",
last_name="Nom",
email="Email",
phone="Téléphone",
comment="Remarque",
),
)
def _table(rows: list[list[str]]) -> ParsedTable:
return ParsedTable(headers=HEADERS, rows=rows)
def _volunteers(session, edition):
return list(
session.exec(
select(Volunteer).where(Volunteer.edition_id == edition.id)
)
)
def test_suggest_mapping_matches_common_headers():
suggestion = suggest_mapping(
["Prénom", "Nom de famille", "Adresse e-mail", "Numéro de portable"]
)
assert suggestion["first_name"] == "Prénom"
assert suggestion["last_name"] == "Nom de famille"
assert suggestion["email"] == "Adresse e-mail"
assert suggestion["phone"] == "Numéro de portable"
def test_happy_path_creates_volunteers(session, edition):
report = run_import(
session=session,
edition=edition,
table=_table(
[
["Alex", "Martin", "alex@ex.fr", "0600000000", "arrive tôt"],
["Sam", "Nguyen", "sam@ex.fr", "", ""],
]
),
mapping=_mapping(),
)
assert (report.created, report.updated, report.skipped) == (2, 0, 0)
volunteers = {v.email: v for v in _volunteers(session, edition)}
assert set(volunteers) == {"alex@ex.fr", "sam@ex.fr"}
assert volunteers["alex@ex.fr"].phone == "0600000000"
assert volunteers["alex@ex.fr"].comment == "arrive tôt"
def test_dry_run_persists_nothing(session, edition):
report = run_import(
session=session,
edition=edition,
table=_table([["Alex", "Martin", "alex@ex.fr", "", ""]]),
mapping=_mapping(dry_run=True),
)
assert report.created == 1
assert report.dry_run is True
assert _volunteers(session, edition) == []
def test_existing_email_and_name_is_updated_not_duplicated(session, edition):
session.add(
Volunteer(
edition_id=edition.id,
email="alex@ex.fr",
first_name="Alex",
last_name="Martin",
phone="0600000000",
)
)
session.commit()
report = run_import(
session=session,
edition=edition,
table=_table([["alex", "MARTIN", "ALEX@ex.fr", "0611111111", ""]]),
mapping=_mapping(),
)
assert (report.created, report.updated) == (0, 1)
assert report.rows[0].issues[0].code == "import.row.updated_existing"
volunteers = _volunteers(session, edition)
assert len(volunteers) == 1
assert volunteers[0].phone == "0611111111"
def test_same_email_different_name_creates_a_second_volunteer(session, edition):
# Un même email peut être partagé (couple, famille) : il ne suffit plus à
# lui seul à identifier « le » bénévole, il faut aussi le nom — sinon un
# second bénévole avec la même adresse était perdu (silencieusement fondu
# dans le premier, ou ignoré selon le cas).
session.add(
Volunteer(
edition_id=edition.id,
email="famille@ex.fr",
first_name="Alex",
last_name="Martin",
)
)
session.commit()
report = run_import(
session=session,
edition=edition,
table=_table([["Sam", "Martin", "famille@ex.fr", "", ""]]),
mapping=_mapping(),
)
assert (report.created, report.updated, report.skipped) == (1, 0, 0)
assert report.rows[0].action == "CREATED"
names = {(v.first_name, v.last_name) for v in _volunteers(session, edition)}
assert names == {("Alex", "Martin"), ("Sam", "Martin")}
def test_duplicate_row_within_file_is_skipped(session, edition):
# Même nom ET même email répétés : une ligne recopiée, pas deux bénévoles.
report = run_import(
session=session,
edition=edition,
table=_table(
[
["Alex", "Martin", "dup@ex.fr", "", ""],
["alex", "MARTIN", "DUP@ex.fr", "", ""],
]
),
mapping=_mapping(),
)
assert (report.created, report.skipped) == (1, 1)
skipped = report.rows[1]
assert skipped.action == "SKIPPED"
assert skipped.issues[0].code == "import.row.duplicate_email_in_file"
assert skipped.issues[0].params["first_line"] == 2
def test_shared_email_within_file_creates_both_with_a_warning(session, edition):
report = run_import(
session=session,
edition=edition,
table=_table(
[
["Alex", "Martin", "famille@ex.fr", "", ""],
["Sam", "Martin", "famille@ex.fr", "", ""],
]
),
mapping=_mapping(),
)
assert (report.created, report.skipped) == (2, 0)
second = report.rows[1]
assert second.action == "CREATED"
assert second.issues[0].code == "import.row.shared_email"
assert second.issues[0].params["first_line"] == 2
names = {(v.first_name, v.last_name) for v in _volunteers(session, edition)}
assert names == {("Alex", "Martin"), ("Sam", "Martin")}
def test_row_without_email_matching_existing_name_warns_but_creates(
session, edition
):
session.add(
Volunteer(
edition_id=edition.id,
first_name="Jean",
last_name="Dupont",
)
)
session.commit()
report = run_import(
session=session,
edition=edition,
table=_table([["jean", "DUPONT", "", "", ""]]),
mapping=_mapping(),
)
assert report.created == 1
assert report.rows[0].issues[0].code == "import.row.possible_duplicate_name"
assert len(_volunteers(session, edition)) == 2
def test_missing_required_field_is_skipped(session, edition):
report = run_import(
session=session,
edition=edition,
table=_table([["", "Martin", "x@ex.fr", "", ""]]),
mapping=_mapping(),
)
assert report.skipped == 1
assert report.rows[0].issues[0].code == "import.row.missing_required_field"
assert _volunteers(session, edition) == []
def test_invalid_email_is_skipped(session, edition):
report = run_import(
session=session,
edition=edition,
table=_table([["Alex", "Martin", "pas-un-email", "", ""]]),
mapping=_mapping(),
)
assert report.skipped == 1
assert report.rows[0].issues[0].code == "import.row.invalid_email"
def test_validate_mapping_reports_missing_and_unknown_columns():
mapping = ImportMapping(
columns=ColumnMapping(first_name="Prénom", last_name="", email="Absente")
)
errors = validate_mapping(_table([]), mapping)
codes = {e.code for e in errors}
assert "import.mapping.required_field_missing" in codes
assert "import.mapping.column_not_found" in codes