wrongmove/crawler/tests/unit/test_cli.py

389 lines
12 KiB
Python
Raw Normal View History

"""Characterization and unit tests for the CLI (main.py)."""
from datetime import datetime
from unittest.mock import AsyncMock, MagicMock, patch
import click
import pytest
from click.testing import CliRunner
from models.listing import FurnishType, ListingType, QueryParameters
from main import build_query_parameters, cli, listing_filter_options
class TestBuildQueryParameters:
"""Tests for build_query_parameters()."""
def test_typical_rent_inputs(self) -> None:
qp = build_query_parameters(
type="RENT",
district=["London", "Camden"],
min_bedrooms=2,
max_bedrooms=4,
min_price=1000,
max_price=3000,
furnish_types=["FURNISHED"],
available_from=datetime(2025, 6, 1),
last_seen_days=7,
min_sqm=50,
)
assert qp.listing_type == ListingType.RENT
assert qp.district_names == {"London", "Camden"}
assert qp.min_bedrooms == 2
assert qp.max_bedrooms == 4
assert qp.min_price == 1000
assert qp.max_price == 3000
assert qp.furnish_types == [FurnishType.FURNISHED]
assert qp.let_date_available_from == datetime(2025, 6, 1)
assert qp.last_seen_days == 7
assert qp.min_sqm == 50
def test_typical_buy_inputs(self) -> None:
qp = build_query_parameters(
type="BUY",
district=["Barnet"],
min_bedrooms=3,
max_bedrooms=5,
min_price=200000,
max_price=500000,
furnish_types=[],
available_from=None,
last_seen_days=14,
)
assert qp.listing_type == ListingType.BUY
assert qp.district_names == {"Barnet"}
assert qp.furnish_types is None
assert qp.let_date_available_from is None
assert qp.min_sqm is None
def test_empty_districts_yields_empty_set(self) -> None:
qp = build_query_parameters(
type="RENT",
district=[],
min_bedrooms=1,
max_bedrooms=10,
min_price=0,
max_price=999999,
furnish_types=[],
available_from=None,
last_seen_days=14,
)
assert qp.district_names == set()
def test_none_districts_yields_empty_set(self) -> None:
qp = build_query_parameters(
type="RENT",
district=None,
min_bedrooms=1,
max_bedrooms=10,
min_price=0,
max_price=999999,
furnish_types=[],
available_from=None,
last_seen_days=14,
)
assert qp.district_names == set()
def test_furnish_types_conversion(self) -> None:
qp = build_query_parameters(
type="RENT",
district=["London"],
min_bedrooms=1,
max_bedrooms=10,
min_price=0,
max_price=999999,
furnish_types=["FURNISHED", "UNFURNISHED"],
available_from=None,
last_seen_days=14,
)
assert qp.furnish_types == [FurnishType.FURNISHED, FurnishType.UNFURNISHED]
def test_empty_furnish_types_yields_none(self) -> None:
qp = build_query_parameters(
type="RENT",
district=["London"],
min_bedrooms=1,
max_bedrooms=10,
min_price=0,
max_price=999999,
furnish_types=[],
available_from=None,
last_seen_days=14,
)
assert qp.furnish_types is None
def test_default_optional_parameters(self) -> None:
qp = build_query_parameters(
type="RENT",
district=["London"],
min_bedrooms=1,
max_bedrooms=10,
min_price=0,
max_price=999999,
furnish_types=[],
available_from=None,
last_seen_days=14,
)
assert qp.radius == 0
assert qp.page_size == 500
assert qp.max_days_since_added == 14
class TestListingFilterOptionsDecorator:
"""Tests for the listing_filter_options decorator."""
def test_applies_all_expected_options(self) -> None:
@click.command()
@listing_filter_options
def dummy_cmd(**kwargs: object) -> None:
pass
expected_option_names = {
"type",
"min_bedrooms",
"max_bedrooms",
"min_price",
"max_price",
"district",
"furnish_types",
"available_from",
"last_seen_days",
"min_sqm",
}
param_names = {p.name for p in dummy_cmd.params}
assert expected_option_names.issubset(param_names), (
f"Missing options: {expected_option_names - param_names}"
)
def test_type_option_is_required(self) -> None:
@click.command()
@listing_filter_options
def dummy_cmd(**kwargs: object) -> None:
pass
type_param = next(p for p in dummy_cmd.params if p.name == "type")
assert type_param.required is True
def test_produces_query_parameters_kwarg(self) -> None:
"""After refactoring, the decorator should produce a query_parameters kwarg."""
captured: dict = {}
@click.command()
@listing_filter_options
def dummy_cmd(query_parameters: QueryParameters) -> None:
captured["qp"] = query_parameters
runner = CliRunner()
result = runner.invoke(dummy_cmd, ["--type", "RENT"])
assert result.exit_code == 0, f"Command failed: {result.output}"
assert isinstance(captured["qp"], QueryParameters)
assert captured["qp"].listing_type == ListingType.RENT
class TestDumpListingsCommand:
"""Tests for the dump-listings CLI command."""
@patch("main.listing_service.refresh_listings", new_callable=AsyncMock)
@patch("main.engine", new_callable=MagicMock)
def test_calls_refresh_listings_with_correct_params(
self,
mock_engine: MagicMock,
mock_refresh: AsyncMock,
) -> None:
from services.listing_service import RefreshResult
mock_refresh.return_value = RefreshResult(
task_id=None,
new_listings_count=5,
message="Fetched 5 new listings",
)
runner = CliRunner()
result = runner.invoke(
cli,
[
"dump-listings",
"--type", "RENT",
"--min-bedrooms", "2",
"--max-bedrooms", "4",
"--min-price", "1000",
"--max-price", "3000",
],
)
assert result.exit_code == 0, f"CLI failed: {result.output}"
mock_refresh.assert_called_once()
call_args = mock_refresh.call_args
qp: QueryParameters = call_args.args[1]
assert qp.listing_type == ListingType.RENT
assert qp.min_bedrooms == 2
assert qp.max_bedrooms == 4
assert qp.min_price == 1000
assert qp.max_price == 3000
assert call_args.kwargs.get("full") is not True
@patch("main.listing_service.refresh_listings", new_callable=AsyncMock)
@patch("main.engine", new_callable=MagicMock)
def test_include_processing_flag_passes_full_true(
self,
mock_engine: MagicMock,
mock_refresh: AsyncMock,
) -> None:
from services.listing_service import RefreshResult
mock_refresh.return_value = RefreshResult(
task_id=None,
new_listings_count=0,
message="Fetched 0 new listings",
)
runner = CliRunner()
result = runner.invoke(
cli,
[
"dump-listings",
"--type", "RENT",
"--include-processing",
],
)
assert result.exit_code == 0, f"CLI failed: {result.output}"
mock_refresh.assert_called_once()
call_kwargs = mock_refresh.call_args.kwargs
assert call_kwargs.get("full") is True
@patch("main.listing_service.refresh_listings", new_callable=AsyncMock)
@patch("main.engine", new_callable=MagicMock)
def test_include_processing_short_flag(
self,
mock_engine: MagicMock,
mock_refresh: AsyncMock,
) -> None:
from services.listing_service import RefreshResult
mock_refresh.return_value = RefreshResult(
task_id=None,
new_listings_count=0,
message="Fetched 0 new listings",
)
runner = CliRunner()
result = runner.invoke(
cli,
[
"dump-listings",
"--type", "RENT",
"-p",
],
)
assert result.exit_code == 0, f"CLI failed: {result.output}"
mock_refresh.assert_called_once()
call_kwargs = mock_refresh.call_args.kwargs
assert call_kwargs.get("full") is True
class TestExportCsvCommand:
"""Tests for the export-csv CLI command."""
@patch("main.export_service.export_to_csv", new_callable=AsyncMock)
@patch("main.engine", new_callable=MagicMock)
def test_calls_export_to_csv(
self,
mock_engine: MagicMock,
mock_export: AsyncMock,
) -> None:
from services.export_service import ExportResult
mock_export.return_value = ExportResult(
success=True,
output_path="/tmp/test.csv",
data=None,
record_count=10,
message="Exported 10 listings to /tmp/test.csv",
)
runner = CliRunner()
result = runner.invoke(
cli,
[
"export-csv",
"--output-file", "/tmp/test.csv",
"--type", "RENT",
],
)
assert result.exit_code == 0, f"CLI failed: {result.output}"
mock_export.assert_called_once()
call_args = mock_export.call_args
qp = call_args[0][2]
assert qp.listing_type == ListingType.RENT
class TestExportImmowebCommand:
"""Tests for the export-immoweb CLI command."""
@patch("main.export_service.export_to_geojson", new_callable=AsyncMock)
@patch("main.engine", new_callable=MagicMock)
def test_calls_export_to_geojson(
self,
mock_engine: MagicMock,
mock_export: AsyncMock,
) -> None:
from services.export_service import ExportResult
mock_export.return_value = ExportResult(
success=True,
output_path="/tmp/test.geojson",
data=None,
record_count=5,
message="Exported 5 listings to /tmp/test.geojson",
)
runner = CliRunner()
result = runner.invoke(
cli,
[
"export-immoweb",
"--output-file", "/tmp/test.geojson",
"--type", "RENT",
],
)
assert result.exit_code == 0, f"CLI failed: {result.output}"
mock_export.assert_called_once()
class TestListDistrictsCommand:
"""Tests for the list-districts CLI command."""
@patch("main.engine", new_callable=MagicMock)
def test_outputs_district_names(self, mock_engine: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["list-districts"])
assert result.exit_code == 0
assert "London" in result.output
assert "Camden" in result.output
assert "Available districts" in result.output
class TestRoutingCommand:
"""Tests for the routing CLI command."""
@patch("main.engine", new_callable=MagicMock)
def test_requires_api_key_env_var(self, mock_engine: MagicMock) -> None:
runner = CliRunner(env={"ROUTING_API_KEY": None})
result = runner.invoke(
cli,
[
"routing",
"--destination-address", "London Bridge",
"--travel-mode", "transit",
"--limit", "1",
],
catch_exceptions=False,
)
assert result.exit_code != 0
assert "ROUTING_API_KEY" in result.output