- Remplace la table trop large par une carte de révision centrée - Une paire à la fois : noms wrappés, score, prix moyens - Valider/Rejeter via fetch() sans rechargement de page - Passage automatique à la paire suivante après chaque action - Compteurs mis à jour en temps réel (en attente/validées/rejetées) - Message de fin avec lien vers /compare quand tout est traité - Ajout tests pytest pour la page /matches Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
386 lines
13 KiB
Python
386 lines
13 KiB
Python
"""
|
|
Tests du dashboard web FastAPI (Sprint 4).
|
|
|
|
Stratégie :
|
|
- Deux familles de fixtures : DB vide et DB avec données
|
|
- On patche tickettracker.config.DB_PATH pour que l'appli pointe sur la DB de test
|
|
- TestClient de FastAPI/httpx pour simuler les requêtes HTTP sans lancer de serveur
|
|
"""
|
|
|
|
from datetime import date
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from tickettracker.db import schema, repository
|
|
from tickettracker.models.receipt import Item, Receipt
|
|
from tickettracker.web.app import app
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Données synthétiques réutilisées par les fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _make_picnic_receipt() -> Receipt:
|
|
"""Ticket Picnic avec deux articles dont un produit commun."""
|
|
return Receipt(
|
|
store="picnic",
|
|
date=date(2026, 1, 10),
|
|
total=15.50,
|
|
delivery_fee=1.99,
|
|
order_id="PICNIC-001",
|
|
items=[
|
|
Item(
|
|
name="Lait demi-écremé",
|
|
quantity=1,
|
|
unit="pièce",
|
|
unit_price=1.05,
|
|
total_price=1.05,
|
|
),
|
|
Item(
|
|
name="Jus d'orange",
|
|
quantity=2,
|
|
unit="pièce",
|
|
unit_price=2.10,
|
|
total_price=4.20,
|
|
),
|
|
],
|
|
)
|
|
|
|
|
|
def _make_leclerc_receipt() -> Receipt:
|
|
"""Ticket Leclerc avec deux articles dont un produit commun."""
|
|
return Receipt(
|
|
store="leclerc",
|
|
date=date(2026, 1, 15),
|
|
total=22.30,
|
|
items=[
|
|
Item(
|
|
name="LAIT DEMI ECREME",
|
|
quantity=1,
|
|
unit="pièce",
|
|
unit_price=0.95,
|
|
total_price=0.95,
|
|
),
|
|
Item(
|
|
name="FARINE BLE",
|
|
quantity=1,
|
|
unit="pièce",
|
|
unit_price=1.20,
|
|
total_price=1.20,
|
|
),
|
|
],
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.fixture
|
|
def db_path(tmp_path: Path) -> Path:
|
|
"""Base SQLite vide dans un répertoire temporaire."""
|
|
path = tmp_path / "test_web.db"
|
|
schema.init_db(path)
|
|
return path
|
|
|
|
|
|
@pytest.fixture
|
|
def db_path_with_data(db_path: Path) -> Path:
|
|
"""Base avec 1 ticket Picnic + 1 ticket Leclerc, 1 produit normalisé en commun."""
|
|
conn = schema.get_connection(db_path)
|
|
try:
|
|
repository.insert_receipt(conn, _make_picnic_receipt())
|
|
repository.insert_receipt(conn, _make_leclerc_receipt())
|
|
|
|
# Normaliser manuellement le produit commun (simule le travail de la CLI normalize)
|
|
with conn:
|
|
conn.execute(
|
|
"UPDATE items SET name_normalized = 'Lait demi-écremé'"
|
|
" WHERE name_raw IN ('Lait demi-écremé', 'LAIT DEMI ECREME')"
|
|
)
|
|
finally:
|
|
conn.close()
|
|
return db_path
|
|
|
|
|
|
@pytest.fixture
|
|
def client(db_path: Path):
|
|
"""TestClient sur la DB vide."""
|
|
with patch("tickettracker.config.DB_PATH", db_path):
|
|
yield TestClient(app)
|
|
|
|
|
|
@pytest.fixture
|
|
def client_with_data(db_path_with_data: Path):
|
|
"""TestClient sur la DB avec données."""
|
|
with patch("tickettracker.config.DB_PATH", db_path_with_data):
|
|
yield TestClient(app)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests HTML — DB vide
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_index_empty_200(client):
|
|
"""Page d'accueil accessible même si la base est vide."""
|
|
resp = client.get("/")
|
|
assert resp.status_code == 200
|
|
|
|
|
|
def test_index_empty_shows_message(client):
|
|
"""Page d'accueil affiche le message 'Aucun ticket' quand la base est vide."""
|
|
resp = client.get("/")
|
|
assert "Aucun ticket" in resp.text
|
|
|
|
|
|
def test_compare_empty_200(client):
|
|
"""Page /compare accessible même si la base est vide."""
|
|
resp = client.get("/compare")
|
|
assert resp.status_code == 200
|
|
|
|
|
|
def test_product_unknown_200(client):
|
|
"""GET /product/<inconnu> retourne 200 (pas 500) — affiche un message 'introuvable'."""
|
|
resp = client.get("/product/ProduitInexistant")
|
|
assert resp.status_code == 200
|
|
assert "introuvable" in resp.text.lower()
|
|
|
|
|
|
def test_receipt_not_found_404(client):
|
|
"""GET /receipt/999 retourne 404 quand le ticket n'existe pas."""
|
|
resp = client.get("/receipt/999")
|
|
assert resp.status_code == 404
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests HTML — DB avec données
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_index_with_data_200(client_with_data):
|
|
"""Page d'accueil est accessible avec des données."""
|
|
resp = client_with_data.get("/")
|
|
assert resp.status_code == 200
|
|
|
|
|
|
def test_index_mentions_store(client_with_data):
|
|
"""Page d'accueil mentionne l'enseigne picnic."""
|
|
resp = client_with_data.get("/")
|
|
assert "picnic" in resp.text.lower()
|
|
|
|
|
|
def test_compare_with_data_shows_product(client_with_data):
|
|
"""Page /compare affiche le produit commun normalisé."""
|
|
resp = client_with_data.get("/compare")
|
|
assert resp.status_code == 200
|
|
assert "Lait demi-écremé" in resp.text
|
|
|
|
|
|
def test_receipt_detail_200(client_with_data):
|
|
"""GET /receipt/1 retourne 200 quand le ticket existe."""
|
|
resp = client_with_data.get("/receipt/1")
|
|
assert resp.status_code == 200
|
|
|
|
|
|
def test_receipt_detail_contains_store(client_with_data):
|
|
"""Page /receipt/1 contient le nom de l'enseigne."""
|
|
resp = client_with_data.get("/receipt/1")
|
|
assert "picnic" in resp.text.lower()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests API — DB vide
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_api_stats_empty(client):
|
|
"""GET /api/stats sur base vide retourne total_receipts = 0."""
|
|
resp = client.get("/api/stats")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["total_receipts"] == 0
|
|
|
|
|
|
def test_api_compare_empty(client):
|
|
"""GET /api/compare sur base vide retourne une liste vide."""
|
|
resp = client.get("/api/compare")
|
|
assert resp.status_code == 200
|
|
assert resp.json() == []
|
|
|
|
|
|
def test_api_receipts_empty(client):
|
|
"""GET /api/receipts sur base vide retourne une liste vide."""
|
|
resp = client.get("/api/receipts")
|
|
assert resp.status_code == 200
|
|
assert resp.json() == []
|
|
|
|
|
|
def test_api_receipt_not_found(client):
|
|
"""GET /api/receipt/999 retourne 404."""
|
|
resp = client.get("/api/receipt/999")
|
|
assert resp.status_code == 404
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests API — DB avec données
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_api_stats_with_data(client_with_data):
|
|
"""GET /api/stats avec 2 tickets retourne total_receipts = 2."""
|
|
resp = client_with_data.get("/api/stats")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["total_receipts"] == 2
|
|
assert data["receipts_by_store"]["picnic"] == 1
|
|
assert data["receipts_by_store"]["leclerc"] == 1
|
|
|
|
|
|
def test_api_compare_returns_common_product(client_with_data):
|
|
"""GET /api/compare retourne le produit normalisé commun aux deux enseignes."""
|
|
resp = client_with_data.get("/api/compare")
|
|
assert resp.status_code == 200
|
|
products = resp.json()
|
|
assert len(products) >= 1
|
|
names = [p["name"] for p in products]
|
|
assert "Lait demi-écremé" in names
|
|
|
|
|
|
def test_api_receipt_detail_has_items(client_with_data):
|
|
"""GET /api/receipt/1 retourne un ticket avec des articles."""
|
|
resp = client_with_data.get("/api/receipt/1")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["store"] == "picnic"
|
|
assert len(data["items"]) == 2
|
|
|
|
|
|
def test_api_product_history(client_with_data):
|
|
"""GET /api/product/<nom>/history retourne l'historique du produit commun."""
|
|
resp = client_with_data.get("/api/product/Lait demi-écremé/history")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["name"] == "Lait demi-écremé"
|
|
assert len(data["history"]) == 2 # 1 occurrence Picnic + 1 Leclerc
|
|
|
|
|
|
def test_api_product_history_not_found(client_with_data):
|
|
"""GET /api/product/<inconnu>/history retourne 404."""
|
|
resp = client_with_data.get("/api/product/ProduitInexistant/history")
|
|
assert resp.status_code == 404
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests /matches — DB vide et avec données
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_matches_page_empty_200(client):
|
|
"""/matches accessible même si la base est vide."""
|
|
resp = client.get("/matches")
|
|
assert resp.status_code == 200
|
|
|
|
|
|
def test_matches_page_shows_no_pending(client):
|
|
"""/matches sans données affiche un message indiquant qu'il n'y a rien à valider."""
|
|
resp = client.get("/matches")
|
|
assert resp.status_code == 200
|
|
# Le template affiche soit "Aucune paire" soit un message d'invitation
|
|
assert "match" in resp.text.lower() or "paire" in resp.text.lower()
|
|
|
|
|
|
@pytest.fixture
|
|
def db_path_with_match(db_path: Path) -> Path:
|
|
"""Base avec 1 paire fuzzy pending dans product_matches."""
|
|
conn = schema.get_connection(db_path)
|
|
try:
|
|
with conn:
|
|
conn.execute(
|
|
"INSERT INTO product_matches "
|
|
"(name_picnic, name_leclerc, score, status, created_at) "
|
|
"VALUES ('lait demi-écrémé', 'lait demi ecreme', 92.0, 'pending', '2026-01-01T00:00:00')"
|
|
)
|
|
finally:
|
|
conn.close()
|
|
return db_path
|
|
|
|
|
|
@pytest.fixture
|
|
def client_with_match(db_path_with_match: Path):
|
|
"""TestClient avec une paire fuzzy pending."""
|
|
with patch("tickettracker.config.DB_PATH", db_path_with_match):
|
|
yield TestClient(app)
|
|
|
|
|
|
def test_matches_page_shows_pending(client_with_match):
|
|
"""/matches affiche la paire pending."""
|
|
resp = client_with_match.get("/matches")
|
|
assert resp.status_code == 200
|
|
assert "lait demi" in resp.text.lower()
|
|
|
|
|
|
def test_api_match_validate_200(client_with_match, db_path_with_match):
|
|
"""POST /api/match/1/validate retourne 200 et met à jour le statut."""
|
|
resp = client_with_match.post("/api/match/1/validate")
|
|
assert resp.status_code == 200
|
|
# Vérification en base
|
|
conn = schema.get_connection(db_path_with_match)
|
|
status = conn.execute("SELECT status FROM product_matches WHERE id=1").fetchone()["status"]
|
|
conn.close()
|
|
assert status == "validated"
|
|
|
|
|
|
def test_api_match_reject_200(client_with_match, db_path_with_match):
|
|
"""POST /api/match/1/reject retourne 200 et met à jour le statut."""
|
|
resp = client_with_match.post("/api/match/1/reject")
|
|
assert resp.status_code == 200
|
|
conn = schema.get_connection(db_path_with_match)
|
|
status = conn.execute("SELECT status FROM product_matches WHERE id=1").fetchone()["status"]
|
|
conn.close()
|
|
assert status == "rejected"
|
|
|
|
|
|
def test_api_match_validate_not_found(client):
|
|
"""POST /api/match/999/validate retourne 404."""
|
|
resp = client.post("/api/match/999/validate")
|
|
assert resp.status_code == 404
|
|
|
|
|
|
def test_api_match_reject_not_found(client):
|
|
"""POST /api/match/999/reject retourne 404."""
|
|
resp = client.post("/api/match/999/reject")
|
|
assert resp.status_code == 404
|
|
|
|
|
|
def test_api_compare_includes_fuzzy_match(db_path_with_data: Path):
|
|
"""GET /api/compare retourne les fuzzy matches validés dans les résultats."""
|
|
# Insérer un fuzzy match validé
|
|
conn = schema.get_connection(db_path_with_data)
|
|
try:
|
|
with conn:
|
|
# Normaliser les articles pour avoir les données dans price_history
|
|
conn.execute(
|
|
"UPDATE items SET name_normalized = 'lait demi-écrémé' "
|
|
"WHERE name_raw = 'Lait demi-écremé'"
|
|
)
|
|
conn.execute(
|
|
"UPDATE items SET name_normalized = 'lait demi ecreme' "
|
|
"WHERE name_raw = 'LAIT DEMI ECREME'"
|
|
)
|
|
# Insérer un fuzzy match validé liant les deux noms
|
|
conn.execute(
|
|
"INSERT INTO product_matches "
|
|
"(name_picnic, name_leclerc, score, status, created_at) "
|
|
"VALUES ('lait demi-écrémé', 'lait demi ecreme', 92.0, 'validated', '2026-01-01T00:00:00')"
|
|
)
|
|
finally:
|
|
conn.close()
|
|
|
|
with patch("tickettracker.config.DB_PATH", db_path_with_data):
|
|
test_client = TestClient(app)
|
|
resp = test_client.get("/api/compare")
|
|
|
|
assert resp.status_code == 200
|
|
products = resp.json()
|
|
match_types = [p["match_type"] for p in products]
|
|
assert "fuzzy" in match_types
|