Files
TicketTracker/tests/test_web.py
laurent 30e4b3e144 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>
2026-02-24 20:04:55 +01:00

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