fix: corrige bug build_database + fiabilise et isole la suite de tests

src/db.py : le remplacement des noms d'organisation nuls utilisait
.name.keep() sur une expression concat_str référençant *_id ; Polars nommait
alors le résultat d'après *_id, écrasant la colonne titulaire_id/acheteur_id et
laissant *_nom inchangée. Remplacé par .alias(col).

Suite de tests — élimination de la pollution inter-tests (callbacks/conn
globaux Dash) qui faisait échouer ~15 tests Selenium en exécution complète :
- tests/conftest.py : DUCKDB_PATH et DATA_SCHEMA_CACHE pointent sur des chemins
  de test isolés (tests/decp.duckdb, tests/schema.cache.json, gitignorés) — on
  ne touche plus jamais aux fichiers versionnés decp.duckdb et
  schema.fixture.json (qui étaient mutés et cassaient la collecte au run
  suivant). Viewport Chrome élargi à 1600px.
- tests/test_db.py : fixture module-scoped qui recharge src.db après le module
  (test_query_marches faisait importlib.reload vers une DB temporaire au schéma
  réduit, polluant le conn global → ColumnNotFoundError ensuite). Assertion mise
  à jour pour le nouveau libellé "[Inconnu de l'INSEE (...)]".

Tests périmés / fragiles corrigés :
- tests/auth/test_oauth_routes.py : mock LinkedIn aligné sur la route (userinfo
  récupéré via .get() séparé) ; destination post-login /compte/abonnement.
- tests/test_main.py : test_002 filtre sur dateNotification (uid retiré du
  tableau), scrollIntoView (colonne hors viewport), attentes de l'application du
  filtre et de la restauration de la persistance ; test_015 via
  wait_for_no_elements.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Colin Maudry
2026-06-30 18:22:27 +02:00
parent 60b59d2d03
commit d8faccab46
5 changed files with 101 additions and 34 deletions
+4 -1
View File
@@ -68,7 +68,10 @@ def _load_source_frame() -> pl.DataFrame:
) )
) )
.otherwise(pl.col(col)) .otherwise(pl.col(col))
.name.keep() # .alias(col) explicite : avec .name.keep(), Polars nomme le résultat
# d'après id_col (référencé dans concat_str), ce qui écrasait la
# colonne *_id et laissait *_nom inchangé au lieu de l'inverse.
.alias(col)
) )
return lff.collect() return lff.collect()
+23 -3
View File
@@ -6,14 +6,31 @@ from src.auth import oauth as oauth_module
@pytest.fixture @pytest.fixture
def fake_userinfo(monkeypatch): def fake_userinfo(monkeypatch):
"""Patche authorize_access_token pour éviter tout appel réseau.""" """Évite tout appel réseau LinkedIn.
La route `linkedin_callback` échange d'abord le code via
`authorize_access_token()` (dont la valeur de retour est ignorée), puis
récupère le profil par un appel séparé `oauth.linkedin.get(.../userinfo)`.
On mocke donc les deux : `authorize_access_token` pour réussir/échouer
l'échange, et `.get()` pour renvoyer le userinfo.
"""
state = {"userinfo": None, "raise": False} state = {"userinfo": None, "raise": False}
def _authorize_access_token(): def _authorize_access_token():
if state["raise"]: if state["raise"]:
raise RuntimeError("token exchange failed") raise RuntimeError("token exchange failed")
return {"userinfo": state["userinfo"]} return {"access_token": "fake-token"}
class _Resp:
def raise_for_status(self):
pass
def json(self):
return state["userinfo"]
def _get(url, *args, **kwargs):
return _Resp()
monkeypatch.setattr( monkeypatch.setattr(
oauth_module.oauth.linkedin, oauth_module.oauth.linkedin,
@@ -21,6 +38,7 @@ def fake_userinfo(monkeypatch):
_authorize_access_token, _authorize_access_token,
raising=False, raising=False,
) )
monkeypatch.setattr(oauth_module.oauth.linkedin, "get", _get, raising=False)
return state return state
@@ -47,7 +65,9 @@ def test_callback_creates_user_and_logs_in(client, fake_userinfo, users_db_path)
} }
resp = client.get("/auth/linkedin/callback") resp = client.get("/auth/linkedin/callback")
assert resp.status_code == 302 assert resp.status_code == 302
assert resp.headers["Location"].endswith("/compte/admin") # Nouvel utilisateur sans abonnement → _post_login_url renvoie vers
# /compte/abonnement (et non /compte/admin, réservé aux abonnés actifs).
assert resp.headers["Location"].endswith("/compte/abonnement")
assert db.get_user_by_email("newbie@example.com") is not None assert db.get_user_by_email("newbie@example.com") is not None
+24 -24
View File
@@ -1,5 +1,6 @@
import datetime import datetime
import os import os
import shutil
from pathlib import Path from pathlib import Path
import polars as pl import polars as pl
@@ -40,18 +41,26 @@ _TEST_DATA = [
} }
] ]
_PARQUET_PATH = Path(os.path.abspath("tests/test.parquet")) _PARQUET_PATH = Path(os.path.abspath("tests/test.parquet"))
_DB_PATH = Path(os.path.abspath("decp.duckdb"))
# Schéma déterministe et hors-ligne pour les tests : on pointe le cache sur un # Base DuckDB de test ISOLÉE : src.db lit le chemin via DUCKDB_PATH (défaut
# fixture commité et on désactive la récupération distante. # ./decp.duckdb). On la place dans tests/ pour ne JAMAIS toucher au decp.duckdb
# de dev/prod — plus besoin de sauvegarder/restaurer. Le nom decp.duckdb
# correspond au motif **/decp.duckdb déjà présent dans .gitignore.
_DB_PATH = Path(os.path.abspath("tests/decp.duckdb"))
os.environ["DUCKDB_PATH"] = str(_DB_PATH)
# Schéma déterministe et hors-ligne : on COPIE le fixture committé vers un cache
# de test jetable (nom schema.cache.json déjà gitignoré) et on pointe
# DATA_SCHEMA_CACHE dessus. Ainsi, toute écriture du cache par le code testé ne
# mute pas tests/schema.fixture.json (versionné). DATA_SCHEMA_PATH est retiré
# pour désactiver toute récupération distante.
_SCHEMA_FIXTURE = Path(os.path.abspath("tests/schema.fixture.json")) _SCHEMA_FIXTURE = Path(os.path.abspath("tests/schema.fixture.json"))
os.environ["DATA_SCHEMA_CACHE"] = str(_SCHEMA_FIXTURE) _SCHEMA_CACHE = Path(os.path.abspath("tests/schema.cache.json"))
shutil.copyfile(_SCHEMA_FIXTURE, _SCHEMA_CACHE)
os.environ["DATA_SCHEMA_CACHE"] = str(_SCHEMA_CACHE)
os.environ.pop("DATA_SCHEMA_PATH", None) os.environ.pop("DATA_SCHEMA_PATH", None)
_DB_BACKUP = _DB_PATH.with_suffix(".duckdb.pytest-backup")
def _cleanup_db_artifacts() -> None: def _cleanup_db_artifacts() -> None:
for artifact in ( for artifact in (
_DB_PATH, _DB_PATH,
@@ -62,30 +71,18 @@ def _cleanup_db_artifacts() -> None:
artifact.unlink() artifact.unlink()
def _backup_db() -> None:
if _DB_PATH.exists():
_DB_PATH.rename(_DB_BACKUP)
def _restore_db() -> None:
_cleanup_db_artifacts()
if _DB_BACKUP.exists():
_DB_BACKUP.rename(_DB_PATH)
# Runs at conftest import, before test modules import src.db (which builds the # Runs at conftest import, before test modules import src.db (which builds the
# DuckDB at import time). Guarantees the test parquet exists and the stale DB # DuckDB at import time depuis DUCKDB_PATH). Garantit un parquet de test frais et
# from a previous `python run.py` is wiped so src.db rebuilds from test data. # une base de test vierge (reconstruite à partir des données de test).
pl.DataFrame(_TEST_DATA).write_parquet(_PARQUET_PATH) pl.DataFrame(_TEST_DATA).write_parquet(_PARQUET_PATH)
_backup_db()
_cleanup_db_artifacts() _cleanup_db_artifacts()
@pytest.fixture(scope="session", autouse=True) @pytest.fixture(scope="session", autouse=True)
def test_data(): def test_data():
yield str(_PARQUET_PATH) yield str(_PARQUET_PATH)
# Teardown: restore the original DuckDB so the next `python run.py` finds it. # Teardown : on nettoie la base de test isolée (jamais le decp.duckdb de dev).
_restore_db() _cleanup_db_artifacts()
def pytest_setup_options(): def pytest_setup_options():
@@ -100,5 +97,8 @@ def pytest_setup_options():
""" """
options = Options() options = Options()
options.add_argument("--headless=new") options.add_argument("--headless=new")
options.add_argument("--window-size=1200,1200") # 1600px de large : le marches_table est large (défilement horizontal) ; un
# viewport étroit laisse les colonnes de droite hors champ → éléments non
# interactifs pour Selenium.
options.add_argument("--window-size=1600,1200")
return options return options
+16 -1
View File
@@ -1,4 +1,5 @@
import datetime import datetime
import importlib
import os import os
import time import time
@@ -8,6 +9,20 @@ import pytest
from src.db import should_rebuild from src.db import should_rebuild
@pytest.fixture(autouse=True, scope="module")
def _restore_global_db_after_module():
"""test_query_marches recharge src.db (importlib.reload) en pointant sur une
DuckDB temporaire au schéma réduit. Sans restauration, le `conn` global de
src.db reste branché sur cette base et casse get_top_org_table dans les
tests Selenium suivants (ColumnNotFoundError: titulaire_distance). On
recharge src.db sur la base de test du conftest une fois ce module terminé."""
yield
import src.db
os.environ["DUCKDB_PATH"] = os.path.abspath("tests/decp.duckdb")
importlib.reload(src.db)
@pytest.fixture @pytest.fixture
def parquet_and_db(tmp_path, monkeypatch): def parquet_and_db(tmp_path, monkeypatch):
parquet = tmp_path / "source.parquet" parquet = tmp_path / "source.parquet"
@@ -175,7 +190,7 @@ def test_build_replaces_null_org_names(built_db):
titulaire_2 = c.execute( titulaire_2 = c.execute(
"SELECT titulaire_nom FROM decp WHERE uid = '2'" "SELECT titulaire_nom FROM decp WHERE uid = '2'"
).fetchone() ).fetchone()
assert titulaire_2[0] == "[Identifiant non reconnu dans la base INSEE]" assert titulaire_2[0] == "[Inconnu de l'INSEE (567)]"
def test_build_creates_derived_tables(built_db): def test_build_creates_derived_tables(built_db):
+34 -5
View File
@@ -3,6 +3,30 @@ from dash.testing.composite import DashComposite
from selenium.webdriver import Keys from selenium.webdriver import Keys
from selenium.webdriver.common.by import By from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webelement import WebElement from selenium.webdriver.remote.webelement import WebElement
from selenium.webdriver.support.ui import WebDriverWait
def _wait_input_value(dash_duo, element: WebElement, expected: str, timeout=8) -> None:
"""Attend qu'un input atteigne la valeur attendue. La restauration de la
persistance des filtres est asynchrone après rechargement de page : sous
charge, lire la valeur immédiatement renvoie une chaîne vide."""
WebDriverWait(dash_duo.driver, timeout).until(
lambda _d: element.get_attribute("value") == expected
)
def _filter_input_in_view(dash_duo, selector, timeout=6) -> WebElement:
"""Attend la présence d'un input de filtre du marches_table et le ramène
dans le viewport. Le tableau a un défilement horizontal : sous charge
(suite complète), la colonne ciblée peut être hors écran, ce qui fait échouer
send_keys avec ElementNotInteractableException alors que l'élément est
pourtant rendu et visible."""
dash_duo.wait_for_element(selector, timeout=timeout)
el: WebElement = dash_duo.find_element(selector)
dash_duo.driver.execute_script(
"arguments[0].scrollIntoView({block: 'center', inline: 'center'});", el
)
return el
def test_001_logo_and_search(dash_duo: DashComposite): def test_001_logo_and_search(dash_duo: DashComposite):
@@ -47,15 +71,21 @@ def test_002_filter_persistence(dash_duo: DashComposite):
filter_input_selector = ( filter_input_selector = (
'.marches_table th[data-dash-column="dateNotification"] input[type="text"]' '.marches_table th[data-dash-column="dateNotification"] input[type="text"]'
) )
dash_duo.wait_for_element(filter_input_selector, timeout=2) return _filter_input_in_view(dash_duo, filter_input_selector)
_filter_input: WebElement = dash_duo.find_element(filter_input_selector)
return _filter_input
for page in ["tableau", "acheteurs/123", "titulaires/345"]: for page in ["tableau", "acheteurs/123", "titulaires/345"]:
filter_input = open_page_and_check_filter_input() filter_input = open_page_and_check_filter_input()
filter_input.send_keys("11") # valeur quelconque, on teste la persistance filter_input.send_keys("11") # valeur quelconque, on teste la persistance
filter_input.send_keys(Keys.ENTER) filter_input.send_keys(Keys.ENTER)
# Attendre que le filtre soit réellement appliqué (la table se vide :
# "11" ne matche aucune dateNotification) AVANT de re-naviguer. Sinon, on
# peut quitter la page avant que le callback de filtre ait écrit la
# persistance → la valeur n'est pas restaurée à la ré-ouverture.
dash_duo.wait_for_no_elements(
'.marches_table td[data-dash-column="dateNotification"] p'
)
filter_input = open_page_and_check_filter_input() filter_input = open_page_and_check_filter_input()
_wait_input_value(dash_duo, filter_input, "11")
assert filter_input.get_attribute("value") == "11" assert filter_input.get_attribute("value") == "11"
@@ -336,8 +366,7 @@ def test_015_tableau_filter_date(dash_duo: DashComposite):
dash_duo.wait_for_page(f"{dash_duo.server_url}/{page}") dash_duo.wait_for_page(f"{dash_duo.server_url}/{page}")
filter_input = '.marches_table th[data-dash-column="dateNotification"] input' filter_input = '.marches_table th[data-dash-column="dateNotification"] input'
filter_cell_result = '.marches_table td[data-dash-column="dateNotification"] p' filter_cell_result = '.marches_table td[data-dash-column="dateNotification"] p'
dash_duo.wait_for_element(filter_input, timeout=2) _filter_input: WebElement = _filter_input_in_view(dash_duo, filter_input)
_filter_input: WebElement = dash_duo.find_element(filter_input)
_filter_input.send_keys("3333") # a dateNotification that doesn't exist _filter_input.send_keys("3333") # a dateNotification that doesn't exist
_filter_input.send_keys(Keys.ENTER) _filter_input.send_keys(Keys.ENTER)
# Le filtrage est asynchrone : attendre la mise à jour du tableau plutôt # Le filtrage est asynchrone : attendre la mise à jour du tableau plutôt