Files
TicketTracker/tests/test_web.py

386 lines
13 KiB
Python
Raw Normal View History

"""
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