332 lines
12 KiB
Python
Executable File
332 lines
12 KiB
Python
Executable File
"""
|
|
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)
|