- 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)
381 lines
14 KiB
Python
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
|