feat: dashboard web FastAPI Sprint 4
Ajout d'un dashboard lecture seule par-dessus la DB SQLite existante. Fichiers créés : - tickettracker/web/queries.py : 7 fonctions SQL (stats, compare, historique...) - tickettracker/web/api.py : router /api/* JSON (FastAPI) - tickettracker/web/app.py : routes HTML + Jinja2 + point d'entrée uvicorn - tickettracker/web/templates/ : base.html, index.html, compare.html, product.html, receipt.html - tickettracker/web/static/style.css : personnalisations Pico CSS - tests/test_web.py : 19 tests (96 passent, 1 xfail OCR) Fichiers modifiés : - requirements.txt : +fastapi, uvicorn[standard], jinja2, python-multipart, httpx - config.py : +DB_PATH (lu depuis TICKETTRACKER_DB_PATH) Lancement : python -m tickettracker.web.app Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
270
tests/test_web.py
Normal file
270
tests/test_web.py
Normal file
@@ -0,0 +1,270 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user