This commit is contained in:
Gilles Soulier
2026-01-14 21:54:55 +01:00
parent c91c0f1fc9
commit d0b73b9319
140 changed files with 5822 additions and 161 deletions

View File

View File

@@ -0,0 +1,130 @@
"""
Test end-to-end: CLI enqueue -> worker -> DB via Redis.
"""
from dataclasses import dataclass
from datetime import datetime
import pytest
import redis
from rq import Queue
from rq.worker import SimpleWorker
from typer.testing import CliRunner
from pricewatch.app.cli import main as cli_main
from pricewatch.app.core.registry import get_registry
from pricewatch.app.core.schema import DebugInfo, DebugStatus, FetchMethod, ProductSnapshot
from pricewatch.app.db.connection import get_session, init_db, reset_engine
from pricewatch.app.db.models import Product, ScrapingLog
from pricewatch.app.stores.base import BaseStore
from pricewatch.app.tasks import scrape as scrape_task
@dataclass
class FakeDbConfig:
url: str
@dataclass
class FakeRedisConfig:
url: str
@dataclass
class FakeAppConfig:
db: FakeDbConfig
redis: FakeRedisConfig
debug: bool = False
enable_db: bool = True
default_use_playwright: bool = False
default_playwright_timeout: int = 1000
class DummyStore(BaseStore):
def __init__(self) -> None:
super().__init__(store_id="dummy")
def match(self, url: str) -> float:
return 1.0 if "example.com" in url else 0.0
def canonicalize(self, url: str) -> str:
return url
def extract_reference(self, url: str) -> str | None:
return "REF-CLI"
def parse(self, html: str, url: str) -> ProductSnapshot:
return ProductSnapshot(
source=self.store_id,
url=url,
fetched_at=datetime(2026, 1, 14, 15, 0, 0),
title="Produit cli",
price=49.99,
currency="EUR",
reference="REF-CLI",
debug=DebugInfo(method=FetchMethod.HTTP, status=DebugStatus.SUCCESS),
)
class DummyFetchResult:
def __init__(self, html: str) -> None:
self.success = True
self.html = html
self.error = None
self.duration_ms = 20
def _redis_available(redis_url: str) -> bool:
try:
conn = redis.from_url(redis_url)
conn.ping()
return True
except Exception:
return False
@pytest.mark.skipif(not _redis_available("redis://localhost:6379/0"), reason="Redis indisponible")
def test_cli_enqueue_worker_persists_db(tmp_path, monkeypatch):
"""Enqueue via CLI, execution worker, persistence DB."""
reset_engine()
db_path = tmp_path / "cli-worker.db"
redis_url = "redis://localhost:6379/0"
config = FakeAppConfig(
db=FakeDbConfig(url=f"sqlite:///{db_path}"),
redis=FakeRedisConfig(url=redis_url),
)
init_db(config)
registry = get_registry()
previous_stores = list(registry._stores)
registry._stores = []
registry.register(DummyStore())
monkeypatch.setattr(cli_main, "get_config", lambda: config)
monkeypatch.setattr(scrape_task, "get_config", lambda: config)
monkeypatch.setattr(scrape_task, "setup_stores", lambda: None)
monkeypatch.setattr(scrape_task, "fetch_http", lambda url: DummyFetchResult("<html></html>"))
queue_name = "test-cli"
redis_conn = redis.from_url(redis_url)
queue = Queue(queue_name, connection=redis_conn)
queue.empty()
runner = CliRunner()
try:
result = runner.invoke(
cli_main.app,
["enqueue", "https://example.com/product", "--queue", queue_name, "--save-db"],
)
assert result.exit_code == 0
worker = SimpleWorker([queue], connection=redis_conn)
worker.work(burst=True)
finally:
queue.empty()
registry._stores = previous_stores
reset_engine()
with get_session(config) as session:
assert session.query(Product).count() == 1
assert session.query(ScrapingLog).count() == 1

View File

@@ -0,0 +1,83 @@
"""
Tests CLI pour enqueue/schedule avec gestion Redis.
"""
from types import SimpleNamespace
from typer.testing import CliRunner
from pricewatch.app.cli import main as cli_main
class DummyScheduler:
def __init__(self, *args, **kwargs) -> None:
self.enqueue_calls = []
self.schedule_calls = []
def enqueue_immediate(self, url, use_playwright=None, save_db=True):
self.enqueue_calls.append((url, use_playwright, save_db))
return SimpleNamespace(id="job-123")
def schedule_product(self, url, interval_hours=24, use_playwright=None, save_db=True):
self.schedule_calls.append((url, interval_hours, use_playwright, save_db))
return SimpleNamespace(job_id="job-456", next_run=SimpleNamespace(isoformat=lambda: "2026"))
def test_enqueue_cli_success(monkeypatch):
"""La commande enqueue retourne un job id."""
runner = CliRunner()
dummy = DummyScheduler()
monkeypatch.setattr(cli_main, "ScrapingScheduler", lambda *args, **kwargs: dummy)
result = runner.invoke(cli_main.app, ["enqueue", "https://example.com/product"])
assert result.exit_code == 0
assert "job-123" in result.output
def test_schedule_cli_success(monkeypatch):
"""La commande schedule retourne un job id et une date."""
runner = CliRunner()
dummy = DummyScheduler()
monkeypatch.setattr(cli_main, "ScrapingScheduler", lambda *args, **kwargs: dummy)
result = runner.invoke(
cli_main.app,
["schedule", "https://example.com/product", "--interval", "12"],
)
assert result.exit_code == 0
assert "job-456" in result.output
assert "2026" in result.output
def test_enqueue_cli_redis_unavailable(monkeypatch):
"""La commande enqueue echoue si Redis est indisponible."""
runner = CliRunner()
def raise_redis(*args, **kwargs):
raise cli_main.RedisUnavailableError("Redis non disponible")
monkeypatch.setattr(cli_main, "ScrapingScheduler", raise_redis)
result = runner.invoke(cli_main.app, ["enqueue", "https://example.com/product"])
assert result.exit_code == 1
assert "Redis non disponible" in result.output
def test_schedule_cli_redis_unavailable(monkeypatch):
"""La commande schedule echoue si Redis est indisponible."""
runner = CliRunner()
def raise_redis(*args, **kwargs):
raise cli_main.RedisUnavailableError("Redis non disponible")
monkeypatch.setattr(cli_main, "ScrapingScheduler", raise_redis)
result = runner.invoke(cli_main.app, ["schedule", "https://example.com/product"])
assert result.exit_code == 1
assert "Redis non disponible" in result.output

0
tests/cli/test_run_db.py Executable file → Normal file
View File

106
tests/cli/test_run_no_db.py Normal file
View File

@@ -0,0 +1,106 @@
"""
Tests pour la compatibilite --no-db.
"""
from dataclasses import dataclass
from pathlib import Path
from typer.testing import CliRunner
from pricewatch.app.cli import main as cli_main
from pricewatch.app.core.registry import get_registry
from pricewatch.app.core.schema import DebugInfo, DebugStatus, FetchMethod, ProductSnapshot
from pricewatch.app.db.connection import get_session, init_db, reset_engine
from pricewatch.app.db.models import Product
from pricewatch.app.stores.base import BaseStore
@dataclass
class FakeDbConfig:
url: str
@dataclass
class FakeAppConfig:
db: FakeDbConfig
debug: bool = False
enable_db: bool = True
class DummyStore(BaseStore):
def __init__(self) -> None:
super().__init__(store_id="dummy")
def match(self, url: str) -> float:
return 1.0 if "example.com" in url else 0.0
def canonicalize(self, url: str) -> str:
return url
def extract_reference(self, url: str) -> str | None:
return "REF-NODB"
def parse(self, html: str, url: str) -> ProductSnapshot:
return ProductSnapshot(
source=self.store_id,
url=url,
title="Produit nodb",
price=9.99,
currency="EUR",
reference="REF-NODB",
debug=DebugInfo(method=FetchMethod.HTTP, status=DebugStatus.SUCCESS),
)
class DummyFetchResult:
def __init__(self, html: str) -> None:
self.success = True
self.html = html
self.error = None
def test_cli_run_no_db(tmp_path, monkeypatch):
"""Le flag --no-db evite toute ecriture DB."""
reset_engine()
db_path = tmp_path / "nodb.db"
config = FakeAppConfig(db=FakeDbConfig(url=f"sqlite:///{db_path}"))
init_db(config)
yaml_path = tmp_path / "config.yaml"
out_path = tmp_path / "out.json"
yaml_path.write_text(
"""
urls:
- "https://example.com/product"
options:
use_playwright: false
save_html: false
save_screenshot: false
""",
encoding="utf-8",
)
registry = get_registry()
previous_stores = list(registry._stores)
registry._stores = []
registry.register(DummyStore())
monkeypatch.setattr(cli_main, "get_config", lambda: config)
monkeypatch.setattr(cli_main, "setup_stores", lambda: None)
monkeypatch.setattr(cli_main, "fetch_http", lambda url: DummyFetchResult("<html></html>"))
runner = CliRunner()
try:
result = runner.invoke(
cli_main.app,
["run", "--yaml", str(yaml_path), "--out", str(out_path), "--no-db"],
)
finally:
registry._stores = previous_stores
reset_engine()
assert result.exit_code == 0
assert out_path.exists()
with get_session(config) as session:
assert session.query(Product).count() == 0

View File

@@ -0,0 +1,54 @@
"""
Tests pour les commandes worker RQ via CLI.
"""
from types import SimpleNamespace
import pytest
from typer.testing import CliRunner
from pricewatch.app.cli import main as cli_main
class DummyRedis:
def ping(self) -> bool:
return True
class DummyWorker:
def __init__(self, queues, connection=None) -> None:
self.queues = queues
self.connection = connection
self.work_calls = []
def work(self, with_scheduler: bool = True):
self.work_calls.append(with_scheduler)
def test_worker_cli_success(monkeypatch):
"""Le worker demarre quand Redis est disponible."""
runner = CliRunner()
dummy_worker = DummyWorker([])
monkeypatch.setattr(cli_main, "Worker", lambda queues, connection=None: dummy_worker)
monkeypatch.setattr(cli_main.redis, "from_url", lambda url: DummyRedis())
result = runner.invoke(cli_main.app, ["worker", "--no-scheduler"])
assert result.exit_code == 0
assert dummy_worker.work_calls == [False]
def test_worker_cli_redis_down(monkeypatch):
"""Le worker echoue proprement si Redis est indisponible."""
runner = CliRunner()
def raise_connection(url):
raise cli_main.redis.exceptions.ConnectionError("redis down")
monkeypatch.setattr(cli_main.redis, "from_url", raise_connection)
result = runner.invoke(cli_main.app, ["worker"])
assert result.exit_code == 1
assert "Impossible de se connecter a Redis" in result.output