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