271 lines
8.5 KiB
Python
271 lines
8.5 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
|