wrongmove/crawler/tests/unit/test_repository.py
Viktor Barzin 150342bb9e
Refactor codebase following Clean Code principles and add 229 tests
- Extract helpers to reduce function sizes (listing_tasks, app.py, query.py, listing_fetcher)
  - Replace nonlocal mutations with _PipelineState dataclass in listing_tasks
  - Fix bugs: isinstance→equality check in repository, verify_exp for OIDC tokens
  - Consolidate duplicate filter methods in listing_repository
  - Move hardcoded config to env vars with backward-compatible defaults
  - Simplify CLI decorator to auto-build QueryParameters
  - Add deprecation docstring to data_access.py
  - Test count: 158 → 387 (all passing)
2026-02-07 20:19:57 +00:00

381 lines
14 KiB
Python

"""Unit tests for ListingRepository."""
from datetime import datetime, timedelta
from unittest.mock import AsyncMock, MagicMock
import pytest
from sqlalchemy import Engine
from models.listing import (
FurnishType,
ListingType,
QueryParameters,
RentListing,
)
from repositories.listing_repository import ListingRepository
class TestListingRepository:
"""Tests for ListingRepository methods."""
async def test_get_listings_empty_db(
self, listing_repository: ListingRepository
) -> None:
"""Test that get_listings returns empty list for empty database."""
listings = await listing_repository.get_listings()
assert listings == []
async def test_get_listings_returns_inserted_listings(
self,
listing_repository: ListingRepository,
sample_rent_listing: RentListing,
) -> None:
"""Test that get_listings returns listings that were inserted."""
await listing_repository.upsert_listings([sample_rent_listing])
listings = await listing_repository.get_listings()
assert len(listings) == 1
assert listings[0].id == sample_rent_listing.id
async def test_upsert_listings_creates_new(
self,
listing_repository: ListingRepository,
sample_rent_listing: RentListing,
) -> None:
"""Test that upsert_listings creates new listings."""
result = await listing_repository.upsert_listings([sample_rent_listing])
assert len(result) == 1
assert result[0].id == sample_rent_listing.id
# Verify it's in the database
listings = await listing_repository.get_listings()
assert len(listings) == 1
async def test_upsert_listings_updates_existing(
self,
listing_repository: ListingRepository,
sample_rent_listing: RentListing,
) -> None:
"""Test that upsert_listings updates existing listings."""
# Insert initial listing
await listing_repository.upsert_listings([sample_rent_listing])
# Update the listing
sample_rent_listing.price = 3000.0
await listing_repository.upsert_listings([sample_rent_listing])
# Verify update
listings = await listing_repository.get_listings()
assert len(listings) == 1
assert listings[0].price == 3000.0
async def test_mark_seen_updates_timestamp(
self,
listing_repository: ListingRepository,
sample_rent_listing: RentListing,
) -> None:
"""Test that mark_seen updates the last_seen timestamp."""
# Set an old timestamp
old_time = datetime.now() - timedelta(days=7)
sample_rent_listing.last_seen = old_time
await listing_repository.upsert_listings([sample_rent_listing])
# Mark as seen
await listing_repository.mark_seen(sample_rent_listing.id)
# Verify timestamp was updated
listings = await listing_repository.get_listings()
assert len(listings) == 1
assert listings[0].last_seen > old_time
async def test_mark_seen_nonexistent_listing(
self, listing_repository: ListingRepository
) -> None:
"""Test that mark_seen handles nonexistent listings gracefully."""
# Should not raise an exception
await listing_repository.mark_seen(999999)
async def test_get_listings_with_only_ids(
self,
listing_repository: ListingRepository,
sample_rent_listings: list[RentListing],
) -> None:
"""Test that get_listings filters by only_ids."""
await listing_repository.upsert_listings(sample_rent_listings)
# Request only specific IDs
listings = await listing_repository.get_listings(only_ids=[1, 3])
assert len(listings) == 2
listing_ids = [l.id for l in listings]
assert 1 in listing_ids
assert 3 in listing_ids
assert 2 not in listing_ids
async def test_get_listings_with_limit(
self,
listing_repository: ListingRepository,
sample_rent_listings: list[RentListing],
) -> None:
"""Test that get_listings respects limit parameter."""
await listing_repository.upsert_listings(sample_rent_listings)
listings = await listing_repository.get_listings(limit=2)
assert len(listings) == 2
class TestListingRepositoryFilters:
"""Tests for ListingRepository query parameter filtering."""
async def test_filter_by_bedrooms(
self,
listing_repository: ListingRepository,
sample_rent_listings: list[RentListing],
) -> None:
"""Test filtering by bedroom count."""
await listing_repository.upsert_listings(sample_rent_listings)
query_params = QueryParameters(
listing_type=ListingType.RENT,
min_bedrooms=2,
max_bedrooms=2,
)
listings = await listing_repository.get_listings(query_parameters=query_params)
assert len(listings) == 1
assert listings[0].number_of_bedrooms == 2
async def test_filter_by_price_range(
self,
listing_repository: ListingRepository,
sample_rent_listings: list[RentListing],
) -> None:
"""Test filtering by price range."""
await listing_repository.upsert_listings(sample_rent_listings)
query_params = QueryParameters(
listing_type=ListingType.RENT,
min_price=1800,
max_price=2500,
)
listings = await listing_repository.get_listings(query_parameters=query_params)
assert len(listings) == 1
assert listings[0].price == 2000.0
async def test_filter_by_min_sqm(
self,
listing_repository: ListingRepository,
sample_rent_listings: list[RentListing],
) -> None:
"""Test filtering by minimum square meters."""
await listing_repository.upsert_listings(sample_rent_listings)
query_params = QueryParameters(
listing_type=ListingType.RENT,
min_sqm=60,
)
listings = await listing_repository.get_listings(query_parameters=query_params)
assert len(listings) == 1
assert listings[0].square_meters == 80.0
async def test_filter_by_furnish_type(
self,
listing_repository: ListingRepository,
sample_rent_listings: list[RentListing],
) -> None:
"""Test filtering by furnish type."""
await listing_repository.upsert_listings(sample_rent_listings)
query_params = QueryParameters(
listing_type=ListingType.RENT,
furnish_types=[FurnishType.UNFURNISHED],
)
listings = await listing_repository.get_listings(query_parameters=query_params)
assert len(listings) == 1
assert listings[0].furnish_type == FurnishType.UNFURNISHED
async def test_filter_by_last_seen_days(
self,
listing_repository: ListingRepository,
sample_rent_listings: list[RentListing],
) -> None:
"""Test filtering by last_seen_days."""
# Make one listing old
sample_rent_listings[0].last_seen = datetime.now() - timedelta(days=30)
await listing_repository.upsert_listings(sample_rent_listings)
query_params = QueryParameters(
listing_type=ListingType.RENT,
last_seen_days=7,
)
listings = await listing_repository.get_listings(query_parameters=query_params)
# Only 2 should be recent enough
assert len(listings) == 2
async def test_combined_filters(
self,
listing_repository: ListingRepository,
sample_rent_listings: list[RentListing],
) -> None:
"""Test combining multiple filters."""
await listing_repository.upsert_listings(sample_rent_listings)
query_params = QueryParameters(
listing_type=ListingType.RENT,
min_bedrooms=1,
max_bedrooms=2,
min_price=1000,
max_price=2500,
furnish_types=[FurnishType.FURNISHED, FurnishType.UNFURNISHED],
)
listings = await listing_repository.get_listings(query_parameters=query_params)
# Should match listings with 1-2 bedrooms in price range
assert len(listings) == 2
class TestListingRepositoryStreaming:
"""Tests for streaming and optimized query methods."""
async def test_count_listings_empty_db(
self, listing_repository: ListingRepository
) -> None:
"""Test count returns 0 for empty database."""
count = listing_repository.count_listings()
assert count == 0
async def test_count_listings_with_data(
self,
listing_repository: ListingRepository,
sample_rent_listings: list[RentListing],
) -> None:
"""Test count returns correct number."""
await listing_repository.upsert_listings(sample_rent_listings)
count = listing_repository.count_listings()
assert count == 3
async def test_count_listings_with_filters(
self,
listing_repository: ListingRepository,
sample_rent_listings: list[RentListing],
) -> None:
"""Test count respects query parameters."""
await listing_repository.upsert_listings(sample_rent_listings)
query_params = QueryParameters(
listing_type=ListingType.RENT,
min_bedrooms=2,
max_bedrooms=3,
)
count = listing_repository.count_listings(query_parameters=query_params)
assert count == 2
async def test_stream_listings_optimized_returns_dicts(
self,
listing_repository: ListingRepository,
sample_rent_listings: list[RentListing],
) -> None:
"""Test optimized streaming returns dict rows."""
await listing_repository.upsert_listings(sample_rent_listings)
results = list(listing_repository.stream_listings_optimized())
assert len(results) == 3
# Each result should be a dict
for row in results:
assert isinstance(row, dict)
assert "id" in row
assert "price" in row
assert "number_of_bedrooms" in row
async def test_stream_listings_optimized_respects_limit(
self,
listing_repository: ListingRepository,
sample_rent_listings: list[RentListing],
) -> None:
"""Test streaming limit parameter."""
await listing_repository.upsert_listings(sample_rent_listings)
results = list(listing_repository.stream_listings_optimized(limit=2))
assert len(results) == 2
async def test_get_listing_ids(
self,
listing_repository: ListingRepository,
sample_rent_listings: list[RentListing],
) -> None:
"""Test get_listing_ids returns set of IDs."""
await listing_repository.upsert_listings(sample_rent_listings)
ids = listing_repository.get_listing_ids()
assert isinstance(ids, set)
assert ids == {1, 2, 3}
async def test_get_listing_ids_empty_db(
self,
listing_repository: ListingRepository,
) -> None:
"""Test get_listing_ids returns empty set for empty database."""
ids = listing_repository.get_listing_ids()
assert isinstance(ids, set)
assert len(ids) == 0
class TestFurnishTypeParsing:
"""Tests for _parse_furnish_type helper."""
def test_parse_furnish_type_none_detailobject(self) -> None:
"""Test that None detailobject returns UNKNOWN."""
listing = MagicMock()
listing.detailobject = None
result = ListingRepository._parse_furnish_type(listing)
assert result == FurnishType.UNKNOWN
def test_parse_furnish_type_missing_property_key(self) -> None:
"""Test that missing 'property' key returns UNKNOWN."""
listing = MagicMock()
listing.detailobject = {}
result = ListingRepository._parse_furnish_type(listing)
assert result == FurnishType.UNKNOWN
def test_parse_furnish_type_missing_let_furnish_type(self) -> None:
"""Test that missing 'letFurnishType' key returns UNKNOWN."""
listing = MagicMock()
listing.detailobject = {"property": {}}
result = ListingRepository._parse_furnish_type(listing)
assert result == FurnishType.UNKNOWN
def test_parse_furnish_type_null_value(self) -> None:
"""Test that null letFurnishType value returns UNKNOWN."""
listing = MagicMock()
listing.detailobject = {"property": {"letFurnishType": None}}
result = ListingRepository._parse_furnish_type(listing)
assert result == FurnishType.UNKNOWN
def test_parse_furnish_type_furnished(self) -> None:
"""Test that 'Furnished' is parsed correctly."""
listing = MagicMock()
listing.detailobject = {"property": {"letFurnishType": "Furnished"}}
result = ListingRepository._parse_furnish_type(listing)
assert result == FurnishType.FURNISHED
def test_parse_furnish_type_unfurnished(self) -> None:
"""Test that 'Unfurnished' is parsed correctly."""
listing = MagicMock()
listing.detailobject = {"property": {"letFurnishType": "Unfurnished"}}
result = ListingRepository._parse_furnish_type(listing)
assert result == FurnishType.UNFURNISHED
def test_parse_furnish_type_part_furnished(self) -> None:
"""Test that 'Part Furnished' is parsed correctly."""
listing = MagicMock()
listing.detailobject = {"property": {"letFurnishType": "Part Furnished"}}
result = ListingRepository._parse_furnish_type(listing)
assert result == FurnishType.PART_FURNISHED
def test_parse_furnish_type_landlord_variant(self) -> None:
"""Test that landlord variants map to ASK_LANDLORD."""
listing = MagicMock()
listing.detailobject = {"property": {"letFurnishType": "Ask Landlord Please"}}
result = ListingRepository._parse_furnish_type(listing)
assert result == FurnishType.ASK_LANDLORD
def test_parse_furnish_type_landlord_case_insensitive(self) -> None:
"""Test that landlord check is case-insensitive."""
listing = MagicMock()
listing.detailobject = {"property": {"letFurnishType": "LANDLORD decides"}}
result = ListingRepository._parse_furnish_type(listing)
assert result == FurnishType.ASK_LANDLORD