chore: sync project files

This commit is contained in:
Gilles Soulier
2026-01-13 19:49:04 +01:00
parent 53f8227941
commit ecda149a4b
149 changed files with 65272 additions and 1 deletions

292
tests/core/test_registry.py Executable file
View File

@@ -0,0 +1,292 @@
"""
Tests pour pricewatch.app.core.registry
Vérifie l'enregistrement des stores, la détection automatique,
et les fonctions helper du registry.
"""
import pytest
from pricewatch.app.core.registry import StoreRegistry
from pricewatch.app.stores.base import BaseStore
from pricewatch.app.core.schema import ProductSnapshot
class MockStore(BaseStore):
"""Mock store pour les tests."""
def __init__(self, store_id: str, match_patterns: dict[str, float]):
"""
Args:
store_id: ID du store
match_patterns: Dict {substring: score} pour simuler match()
"""
super().__init__(store_id=store_id, selectors_path=None)
self.match_patterns = match_patterns
def match(self, url: str) -> float:
"""Retourne un score basé sur les patterns configurés."""
if not url:
return 0.0
url_lower = url.lower()
for pattern, score in self.match_patterns.items():
if pattern in url_lower:
return score
return 0.0
def canonicalize(self, url: str) -> str:
"""Mock canonicalize."""
return url
def extract_reference(self, url: str) -> str | None:
"""Mock extract_reference."""
return "TEST_REF"
def parse(self, html: str, url: str, **kwargs) -> ProductSnapshot:
"""Mock parse - pas utilisé dans les tests du registry."""
raise NotImplementedError("Mock parse not implemented")
class TestStoreRegistry:
"""Tests du StoreRegistry."""
@pytest.fixture
def registry(self) -> StoreRegistry:
"""Fixture: Registry vide."""
return StoreRegistry()
@pytest.fixture
def mock_amazon(self) -> MockStore:
"""Fixture: Mock Amazon store."""
return MockStore(
store_id="amazon",
match_patterns={"amazon.fr": 0.9, "amazon.com": 0.8},
)
@pytest.fixture
def mock_cdiscount(self) -> MockStore:
"""Fixture: Mock Cdiscount store."""
return MockStore(
store_id="cdiscount",
match_patterns={"cdiscount.com": 0.9},
)
def test_registry_init_empty(self, registry):
"""Un registry vide ne contient aucun store."""
assert len(registry) == 0
assert registry.list_stores() == []
def test_register_single_store(self, registry, mock_amazon):
"""Enregistre un seul store."""
registry.register(mock_amazon)
assert len(registry) == 1
assert "amazon" in registry.list_stores()
def test_register_multiple_stores(self, registry, mock_amazon, mock_cdiscount):
"""Enregistre plusieurs stores."""
registry.register(mock_amazon)
registry.register(mock_cdiscount)
assert len(registry) == 2
assert set(registry.list_stores()) == {"amazon", "cdiscount"}
def test_register_invalid_type(self, registry):
"""Enregistrer un objet non-BaseStore doit échouer."""
with pytest.raises(TypeError) as exc_info:
registry.register("not a store")
assert "Expected BaseStore" in str(exc_info.value)
def test_register_duplicate_replaces(self, registry, mock_amazon):
"""Enregistrer deux fois le même store_id remplace le premier."""
registry.register(mock_amazon)
assert len(registry) == 1
# Créer un autre mock avec le même ID
duplicate = MockStore(store_id="amazon", match_patterns={"amazon.es": 0.7})
registry.register(duplicate)
# Doit toujours avoir un seul store
assert len(registry) == 1
assert "amazon" in registry.list_stores()
# Doit avoir le nouveau store
store = registry.get_store("amazon")
assert store is duplicate
def test_get_store_existing(self, registry, mock_amazon):
"""Récupère un store existant."""
registry.register(mock_amazon)
store = registry.get_store("amazon")
assert store is mock_amazon
def test_get_store_non_existing(self, registry):
"""Récupère un store inexistant retourne None."""
store = registry.get_store("nonexistent")
assert store is None
def test_unregister_existing(self, registry, mock_amazon):
"""Désenregistre un store existant."""
registry.register(mock_amazon)
assert len(registry) == 1
removed = registry.unregister("amazon")
assert removed is True
assert len(registry) == 0
assert "amazon" not in registry.list_stores()
def test_unregister_non_existing(self, registry):
"""Désenregistre un store inexistant retourne False."""
removed = registry.unregister("nonexistent")
assert removed is False
def test_detect_store_empty_url(self, registry, mock_amazon):
"""URL vide retourne None."""
registry.register(mock_amazon)
store = registry.detect_store("")
assert store is None
def test_detect_store_whitespace_url(self, registry, mock_amazon):
"""URL avec espaces retourne None."""
registry.register(mock_amazon)
store = registry.detect_store(" ")
assert store is None
def test_detect_store_empty_registry(self, registry):
"""Registry vide retourne None."""
store = registry.detect_store("https://example.com")
assert store is None
def test_detect_store_single_match(self, registry, mock_amazon):
"""Détecte un store avec un seul match."""
registry.register(mock_amazon)
store = registry.detect_store("https://www.amazon.fr/dp/B08N5WRWNW")
assert store is mock_amazon
def test_detect_store_no_match(self, registry, mock_amazon):
"""Aucun store ne match retourne None."""
registry.register(mock_amazon)
store = registry.detect_store("https://www.ebay.com/item/123")
assert store is None
def test_detect_store_multiple_matches_best_score(
self, registry, mock_amazon, mock_cdiscount
):
"""Avec plusieurs matches, retourne le meilleur score."""
registry.register(mock_amazon)
registry.register(mock_cdiscount)
# Test Amazon
store = registry.detect_store("https://www.amazon.fr/dp/B08N5WRWNW")
assert store is mock_amazon
# Test Cdiscount
store = registry.detect_store("https://www.cdiscount.com/product/123")
assert store is mock_cdiscount
def test_detect_store_ambiguous_url_best_score(self, registry):
"""URL ambiguë: retourne le store avec le meilleur score."""
# Créer deux stores avec des scores différents pour la même URL
store_a = MockStore(store_id="store_a", match_patterns={"example.com": 0.7})
store_b = MockStore(store_id="store_b", match_patterns={"example.com": 0.9})
registry.register(store_a)
registry.register(store_b)
store = registry.detect_store("https://www.example.com")
assert store is store_b # Meilleur score (0.9 vs 0.7)
def test_detect_store_exception_in_match(self, registry, mock_amazon):
"""Si un store.match() lève une exception, continue avec les autres."""
# Créer un store qui crash
class BrokenStore(MockStore):
def match(self, url: str) -> float:
raise RuntimeError("Simulated crash")
broken = BrokenStore(store_id="broken", match_patterns={})
registry.register(broken)
registry.register(mock_amazon)
# Doit quand même détecter Amazon malgré le crash du broken store
store = registry.detect_store("https://www.amazon.fr/dp/B08N5WRWNW")
assert store is mock_amazon
def test_list_stores_empty(self, registry):
"""Liste des stores vide."""
assert registry.list_stores() == []
def test_list_stores_multiple(self, registry, mock_amazon, mock_cdiscount):
"""Liste des stores avec plusieurs enregistrés."""
registry.register(mock_amazon)
registry.register(mock_cdiscount)
stores = registry.list_stores()
assert len(stores) == 2
assert "amazon" in stores
assert "cdiscount" in stores
def test_len_operator(self, registry, mock_amazon, mock_cdiscount):
"""Opérateur len() retourne le nombre de stores."""
assert len(registry) == 0
registry.register(mock_amazon)
assert len(registry) == 1
registry.register(mock_cdiscount)
assert len(registry) == 2
registry.unregister("amazon")
assert len(registry) == 1
def test_repr(self, registry, mock_amazon, mock_cdiscount):
"""Représentation string du registry."""
registry.register(mock_amazon)
registry.register(mock_cdiscount)
repr_str = repr(registry)
assert "StoreRegistry" in repr_str
assert "amazon" in repr_str
assert "cdiscount" in repr_str
class TestRegistryGlobalFunctions:
"""Tests des fonctions globales du module registry."""
def test_get_registry_singleton(self):
"""get_registry() retourne toujours la même instance."""
from pricewatch.app.core.registry import get_registry
registry1 = get_registry()
registry2 = get_registry()
assert registry1 is registry2
def test_register_store_global(self):
"""register_store() enregistre dans le registry global."""
from pricewatch.app.core.registry import get_registry, register_store
# Nettoyer le registry global pour le test
registry = get_registry()
initial_count = len(registry)
mock = MockStore(store_id="test_global", match_patterns={})
register_store(mock)
assert len(registry) == initial_count + 1
assert "test_global" in registry.list_stores()
# Cleanup
registry.unregister("test_global")
def test_detect_store_global(self):
"""detect_store() utilise le registry global."""
from pricewatch.app.core.registry import detect_store, get_registry, register_store
# Nettoyer le registry global pour le test
registry = get_registry()
mock = MockStore(store_id="test_detect", match_patterns={"testsite.com": 0.9})
register_store(mock)
store = detect_store("https://www.testsite.com/product")
assert store is not None
assert store.store_id == "test_detect"
# Cleanup
registry.unregister("test_detect")

331
tests/core/test_schema.py Executable file
View File

@@ -0,0 +1,331 @@
"""
Tests pour pricewatch.app.core.schema
Vérifie la validation Pydantic, la serialization JSON,
et les méthodes helper de ProductSnapshot.
"""
import json
from datetime import datetime
import pytest
from pydantic import ValidationError
from pricewatch.app.core.schema import (
DebugInfo,
DebugStatus,
FetchMethod,
ProductSnapshot,
StockStatus,
)
class TestEnums:
"""Tests des enums."""
def test_stock_status_values(self):
"""Vérifie les valeurs de StockStatus."""
assert StockStatus.IN_STOCK.value == "in_stock"
assert StockStatus.OUT_OF_STOCK.value == "out_of_stock"
assert StockStatus.UNKNOWN.value == "unknown"
def test_fetch_method_values(self):
"""Vérifie les valeurs de FetchMethod."""
assert FetchMethod.HTTP.value == "http"
assert FetchMethod.PLAYWRIGHT.value == "playwright"
def test_debug_status_values(self):
"""Vérifie les valeurs de DebugStatus."""
assert DebugStatus.SUCCESS.value == "success"
assert DebugStatus.PARTIAL.value == "partial"
assert DebugStatus.FAILED.value == "failed"
class TestDebugInfo:
"""Tests du modèle DebugInfo."""
def test_debug_info_creation(self):
"""Crée un DebugInfo valide."""
debug = DebugInfo(
method=FetchMethod.HTTP,
status=DebugStatus.SUCCESS,
duration_ms=1500,
html_size_bytes=120000,
)
assert debug.method == FetchMethod.HTTP
assert debug.status == DebugStatus.SUCCESS
assert debug.duration_ms == 1500
assert debug.html_size_bytes == 120000
assert debug.errors == []
assert debug.notes == []
def test_debug_info_with_errors(self):
"""Crée un DebugInfo avec des erreurs."""
debug = DebugInfo(
method=FetchMethod.PLAYWRIGHT,
status=DebugStatus.FAILED,
errors=["403 Forbidden", "Captcha detected"],
notes=["Fallback to Playwright triggered"],
)
assert len(debug.errors) == 2
assert "403 Forbidden" in debug.errors
assert len(debug.notes) == 1
def test_debug_info_defaults(self):
"""Vérifie les valeurs par défaut."""
debug = DebugInfo(method=FetchMethod.HTTP, status=DebugStatus.SUCCESS)
assert debug.errors == []
assert debug.notes == []
assert debug.duration_ms is None
assert debug.html_size_bytes is None
class TestProductSnapshot:
"""Tests du modèle ProductSnapshot."""
@pytest.fixture
def minimal_snapshot(self) -> ProductSnapshot:
"""Fixture: ProductSnapshot minimal valide."""
return ProductSnapshot(
source="amazon",
url="https://www.amazon.fr/dp/B08N5WRWNW",
debug=DebugInfo(method=FetchMethod.HTTP, status=DebugStatus.SUCCESS),
)
@pytest.fixture
def complete_snapshot(self) -> ProductSnapshot:
"""Fixture: ProductSnapshot complet."""
return ProductSnapshot(
source="amazon",
url="https://www.amazon.fr/dp/B08N5WRWNW",
fetched_at=datetime(2026, 1, 13, 10, 30, 0),
title="PlayStation 5",
price=499.99,
currency="EUR",
shipping_cost=0.0,
stock_status=StockStatus.IN_STOCK,
reference="B08N5WRWNW",
category="Jeux vidéo",
images=[
"https://example.com/image1.jpg",
"https://example.com/image2.jpg",
],
specs={
"Marque": "Sony",
"Couleur": "Blanc",
"Poids": "4.5 kg",
},
debug=DebugInfo(
method=FetchMethod.HTTP,
status=DebugStatus.SUCCESS,
duration_ms=1200,
html_size_bytes=145000,
),
)
def test_create_minimal_snapshot(self, minimal_snapshot):
"""Crée un ProductSnapshot minimal."""
assert minimal_snapshot.source == "amazon"
assert minimal_snapshot.url == "https://www.amazon.fr/dp/B08N5WRWNW"
assert minimal_snapshot.title is None
assert minimal_snapshot.price is None
assert minimal_snapshot.currency == "EUR" # Default
assert minimal_snapshot.stock_status == StockStatus.UNKNOWN # Default
def test_create_complete_snapshot(self, complete_snapshot):
"""Crée un ProductSnapshot complet."""
assert complete_snapshot.source == "amazon"
assert complete_snapshot.title == "PlayStation 5"
assert complete_snapshot.price == 499.99
assert complete_snapshot.reference == "B08N5WRWNW"
assert len(complete_snapshot.images) == 2
assert len(complete_snapshot.specs) == 3
def test_url_validation_empty(self):
"""URL vide doit échouer."""
with pytest.raises(ValidationError) as exc_info:
ProductSnapshot(
source="amazon",
url="",
debug=DebugInfo(method=FetchMethod.HTTP, status=DebugStatus.SUCCESS),
)
assert "URL cannot be empty" in str(exc_info.value)
def test_url_validation_whitespace(self):
"""URL avec seulement des espaces doit échouer."""
with pytest.raises(ValidationError) as exc_info:
ProductSnapshot(
source="amazon",
url=" ",
debug=DebugInfo(method=FetchMethod.HTTP, status=DebugStatus.SUCCESS),
)
assert "URL cannot be empty" in str(exc_info.value)
def test_source_validation_empty(self):
"""Source vide doit échouer."""
with pytest.raises(ValidationError) as exc_info:
ProductSnapshot(
source="",
url="https://example.com",
debug=DebugInfo(method=FetchMethod.HTTP, status=DebugStatus.SUCCESS),
)
assert "Source cannot be empty" in str(exc_info.value)
def test_source_normalization(self):
"""Source doit être normalisée en lowercase."""
snapshot = ProductSnapshot(
source="AMAZON",
url="https://example.com",
debug=DebugInfo(method=FetchMethod.HTTP, status=DebugStatus.SUCCESS),
)
assert snapshot.source == "amazon"
def test_price_negative(self):
"""Prix négatif doit échouer."""
with pytest.raises(ValidationError):
ProductSnapshot(
source="amazon",
url="https://example.com",
price=-10.0,
debug=DebugInfo(method=FetchMethod.HTTP, status=DebugStatus.SUCCESS),
)
def test_shipping_cost_negative(self):
"""Frais de port négatifs doivent échouer."""
with pytest.raises(ValidationError):
ProductSnapshot(
source="amazon",
url="https://example.com",
shipping_cost=-5.0,
debug=DebugInfo(method=FetchMethod.HTTP, status=DebugStatus.SUCCESS),
)
def test_images_validation(self):
"""Les URLs d'images vides doivent être filtrées."""
snapshot = ProductSnapshot(
source="amazon",
url="https://example.com",
images=["https://img1.jpg", "", " ", "https://img2.jpg"],
debug=DebugInfo(method=FetchMethod.HTTP, status=DebugStatus.SUCCESS),
)
assert len(snapshot.images) == 2
assert "https://img1.jpg" in snapshot.images
assert "https://img2.jpg" in snapshot.images
def test_is_complete_with_title_and_price(self, complete_snapshot):
"""Un snapshot avec titre et prix est complet."""
assert complete_snapshot.is_complete() is True
def test_is_complete_without_price(self, minimal_snapshot):
"""Un snapshot sans prix n'est pas complet."""
minimal_snapshot.title = "Test Product"
assert minimal_snapshot.is_complete() is False
def test_is_complete_without_title(self, minimal_snapshot):
"""Un snapshot sans titre n'est pas complet."""
minimal_snapshot.price = 99.99
assert minimal_snapshot.is_complete() is False
def test_is_complete_minimal(self, minimal_snapshot):
"""Un snapshot minimal n'est pas complet."""
assert minimal_snapshot.is_complete() is False
def test_add_error(self, minimal_snapshot):
"""Ajoute une erreur au debug."""
minimal_snapshot.add_error("Test error 1")
minimal_snapshot.add_error("Test error 2")
assert len(minimal_snapshot.debug.errors) == 2
assert "Test error 1" in minimal_snapshot.debug.errors
def test_add_note(self, minimal_snapshot):
"""Ajoute une note au debug."""
minimal_snapshot.add_note("Test note 1")
minimal_snapshot.add_note("Test note 2")
assert len(minimal_snapshot.debug.notes) == 2
assert "Test note 1" in minimal_snapshot.debug.notes
def test_to_dict(self, complete_snapshot):
"""Serialization vers dict."""
data = complete_snapshot.to_dict()
assert isinstance(data, dict)
assert data["source"] == "amazon"
assert data["title"] == "PlayStation 5"
assert data["price"] == 499.99
assert isinstance(data["fetched_at"], str) # ISO format
assert data["debug"]["method"] == "http"
def test_to_json(self, complete_snapshot):
"""Serialization vers JSON."""
json_str = complete_snapshot.to_json()
assert isinstance(json_str, str)
# Vérifie que c'est du JSON valide
data = json.loads(json_str)
assert data["source"] == "amazon"
assert data["title"] == "PlayStation 5"
assert data["price"] == 499.99
def test_from_json(self, complete_snapshot):
"""Désérialisation depuis JSON."""
# Serialize puis deserialize
json_str = complete_snapshot.to_json()
restored = ProductSnapshot.from_json(json_str)
assert restored.source == complete_snapshot.source
assert restored.title == complete_snapshot.title
assert restored.price == complete_snapshot.price
assert restored.reference == complete_snapshot.reference
def test_to_dict_and_from_json_roundtrip(self, complete_snapshot):
"""Roundtrip complet dict → JSON → ProductSnapshot."""
# to_dict puis JSON puis from_json
json_str = json.dumps(complete_snapshot.to_dict())
restored = ProductSnapshot.from_json(json_str)
assert restored.source == complete_snapshot.source
assert restored.title == complete_snapshot.title
assert restored.price == complete_snapshot.price
def test_enum_serialization(self):
"""Les enums doivent être sérialisés en string."""
snapshot = ProductSnapshot(
source="amazon",
url="https://example.com",
stock_status=StockStatus.IN_STOCK,
debug=DebugInfo(method=FetchMethod.PLAYWRIGHT, status=DebugStatus.PARTIAL),
)
data = snapshot.to_dict()
assert data["stock_status"] == "in_stock"
assert data["debug"]["method"] == "playwright"
assert data["debug"]["status"] == "partial"
def test_fetched_at_default(self):
"""fetched_at doit avoir une valeur par défaut."""
snapshot = ProductSnapshot(
source="amazon",
url="https://example.com",
debug=DebugInfo(method=FetchMethod.HTTP, status=DebugStatus.SUCCESS),
)
assert snapshot.fetched_at is not None
assert isinstance(snapshot.fetched_at, datetime)
def test_specs_default(self):
"""specs doit être un dict vide par défaut."""
snapshot = ProductSnapshot(
source="amazon",
url="https://example.com",
debug=DebugInfo(method=FetchMethod.HTTP, status=DebugStatus.SUCCESS),
)
assert snapshot.specs == {}
assert isinstance(snapshot.specs, dict)
def test_images_default(self):
"""images doit être une liste vide par défaut."""
snapshot = ProductSnapshot(
source="amazon",
url="https://example.com",
debug=DebugInfo(method=FetchMethod.HTTP, status=DebugStatus.SUCCESS),
)
assert snapshot.images == []
assert isinstance(snapshot.images, list)