feat: improve SPA scraping and increase test coverage

- Add SPA support for Playwright with wait_for_network_idle and extra_wait_ms
- Add BaseStore.get_spa_config() and requires_playwright() methods
- Implement AliExpress SPA config with JSON price extraction patterns
- Fix Amazon price parsing to prioritize whole+fraction combination
- Fix AliExpress regex patterns (remove double backslashes)
- Add CLI tests: detect, doctor, fetch, parse, run commands
- Add API tests: auth, logs, products, scraping_logs, webhooks

Tests: 417 passed, 85% coverage

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Gilles Soulier
2026-01-17 14:46:55 +01:00
parent cf7c415e22
commit 152c2724fc
14 changed files with 1307 additions and 22 deletions

View File

@@ -0,0 +1,267 @@
"""Tests fonctions API produits avec mocks."""
from datetime import datetime
from unittest.mock import MagicMock, patch
import pytest
from fastapi import HTTPException
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
from pricewatch.app.api.main import (
create_product,
get_product,
update_product,
delete_product,
list_prices,
create_price,
update_price,
delete_price,
)
from pricewatch.app.api.schemas import ProductCreate, ProductUpdate, PriceHistoryCreate, PriceHistoryUpdate
class MockProduct:
"""Mock Product model."""
def __init__(self, **kwargs):
self.id = kwargs.get("id", 1)
self.source = kwargs.get("source", "amazon")
self.reference = kwargs.get("reference", "REF123")
self.url = kwargs.get("url", "https://example.com")
self.title = kwargs.get("title", "Test Product")
self.category = kwargs.get("category")
self.description = kwargs.get("description")
self.currency = kwargs.get("currency", "EUR")
self.msrp = kwargs.get("msrp")
self.first_seen_at = kwargs.get("first_seen_at", datetime.now())
self.last_updated_at = kwargs.get("last_updated_at", datetime.now())
class MockPrice:
"""Mock PriceHistory model."""
def __init__(self, **kwargs):
self.id = kwargs.get("id", 1)
self.product_id = kwargs.get("product_id", 1)
self.price = kwargs.get("price", 99.99)
self.shipping_cost = kwargs.get("shipping_cost")
self.stock_status = kwargs.get("stock_status", "in_stock")
self.fetch_method = kwargs.get("fetch_method", "http")
self.fetch_status = kwargs.get("fetch_status", "success")
self.fetched_at = kwargs.get("fetched_at", datetime.now())
class TestCreateProduct:
"""Tests create_product."""
def test_create_success(self):
"""Cree un produit avec succes."""
session = MagicMock()
session.add = MagicMock()
session.commit = MagicMock()
session.refresh = MagicMock()
payload = ProductCreate(
source="amazon",
reference="NEW123",
url="https://amazon.fr/dp/NEW123",
title="New Product",
currency="EUR",
)
with patch("pricewatch.app.api.main.Product") as MockProductClass:
mock_product = MockProduct(reference="NEW123")
MockProductClass.return_value = mock_product
with patch("pricewatch.app.api.main._product_to_out") as mock_to_out:
mock_to_out.return_value = MagicMock()
result = create_product(payload, session)
session.add.assert_called_once()
session.commit.assert_called_once()
def test_create_duplicate(self):
"""Cree un produit duplique leve 409."""
session = MagicMock()
session.add = MagicMock()
session.commit = MagicMock(side_effect=IntegrityError("duplicate", {}, None))
session.rollback = MagicMock()
payload = ProductCreate(
source="amazon",
reference="DUPE",
url="https://amazon.fr/dp/DUPE",
title="Duplicate",
currency="EUR",
)
with patch("pricewatch.app.api.main.Product"):
with pytest.raises(HTTPException) as exc_info:
create_product(payload, session)
assert exc_info.value.status_code == 409
def test_create_db_error(self):
"""Erreur DB leve 500."""
session = MagicMock()
session.add = MagicMock()
session.commit = MagicMock(side_effect=SQLAlchemyError("db error"))
session.rollback = MagicMock()
payload = ProductCreate(
source="amazon",
reference="ERR",
url="https://amazon.fr/dp/ERR",
title="Error",
currency="EUR",
)
with patch("pricewatch.app.api.main.Product"):
with pytest.raises(HTTPException) as exc_info:
create_product(payload, session)
assert exc_info.value.status_code == 500
class TestGetProduct:
"""Tests get_product."""
def test_get_not_found(self):
"""Produit non trouve leve 404."""
session = MagicMock()
mock_query = MagicMock()
mock_query.filter.return_value.one_or_none.return_value = None
session.query.return_value = mock_query
with pytest.raises(HTTPException) as exc_info:
get_product(99999, session)
assert exc_info.value.status_code == 404
class TestUpdateProduct:
"""Tests update_product."""
def test_update_not_found(self):
"""Update produit non trouve leve 404."""
session = MagicMock()
mock_query = MagicMock()
mock_query.filter.return_value.one_or_none.return_value = None
session.query.return_value = mock_query
payload = ProductUpdate(title="Updated")
with pytest.raises(HTTPException) as exc_info:
update_product(99999, payload, session)
assert exc_info.value.status_code == 404
def test_update_db_error(self):
"""Erreur DB lors d'update leve 500."""
session = MagicMock()
mock_product = MockProduct()
mock_query = MagicMock()
mock_query.filter.return_value.one_or_none.return_value = mock_product
session.query.return_value = mock_query
session.commit = MagicMock(side_effect=SQLAlchemyError("error"))
session.rollback = MagicMock()
payload = ProductUpdate(title="Updated")
with pytest.raises(HTTPException) as exc_info:
update_product(1, payload, session)
assert exc_info.value.status_code == 500
class TestDeleteProduct:
"""Tests delete_product."""
def test_delete_not_found(self):
"""Delete produit non trouve leve 404."""
session = MagicMock()
mock_query = MagicMock()
mock_query.filter.return_value.one_or_none.return_value = None
session.query.return_value = mock_query
with pytest.raises(HTTPException) as exc_info:
delete_product(99999, session)
assert exc_info.value.status_code == 404
def test_delete_success(self):
"""Delete produit avec succes."""
session = MagicMock()
mock_product = MockProduct()
mock_query = MagicMock()
mock_query.filter.return_value.one_or_none.return_value = mock_product
session.query.return_value = mock_query
session.delete = MagicMock()
session.commit = MagicMock()
result = delete_product(1, session)
assert result == {"status": "deleted"}
session.delete.assert_called_once()
def test_delete_db_error(self):
"""Erreur DB lors de delete leve 500."""
session = MagicMock()
mock_product = MockProduct()
mock_query = MagicMock()
mock_query.filter.return_value.one_or_none.return_value = mock_product
session.query.return_value = mock_query
session.delete = MagicMock()
session.commit = MagicMock(side_effect=SQLAlchemyError("error"))
session.rollback = MagicMock()
with pytest.raises(HTTPException) as exc_info:
delete_product(1, session)
assert exc_info.value.status_code == 500
class TestCreatePrice:
"""Tests create_price."""
def test_create_price_db_error(self):
"""Erreur DB lors de creation prix."""
session = MagicMock()
session.add = MagicMock()
session.commit = MagicMock(side_effect=SQLAlchemyError("error"))
session.rollback = MagicMock()
payload = PriceHistoryCreate(
product_id=1,
price=99.99,
fetch_method="http",
fetch_status="success",
fetched_at=datetime.now(),
)
with patch("pricewatch.app.api.main.PriceHistory"):
with pytest.raises(HTTPException) as exc_info:
create_price(payload, session)
assert exc_info.value.status_code == 500
class TestUpdatePrice:
"""Tests update_price."""
def test_update_price_not_found(self):
"""Update prix non trouve leve 404."""
session = MagicMock()
mock_query = MagicMock()
mock_query.filter.return_value.one_or_none.return_value = None
session.query.return_value = mock_query
payload = PriceHistoryUpdate(price=149.99)
with pytest.raises(HTTPException) as exc_info:
update_price(99999, payload, session)
assert exc_info.value.status_code == 404
class TestDeletePrice:
"""Tests delete_price."""
def test_delete_price_not_found(self):
"""Delete prix non trouve leve 404."""
session = MagicMock()
mock_query = MagicMock()
mock_query.filter.return_value.one_or_none.return_value = None
session.query.return_value = mock_query
with pytest.raises(HTTPException) as exc_info:
delete_price(99999, session)
assert exc_info.value.status_code == 404