test: cover propertyfinder matching rules
This commit is contained in:
@@ -32,4 +32,6 @@ jobs:
|
|||||||
needs: hygiene
|
needs: hygiene
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
- run: python3 -m pip install -r requirements.txt
|
||||||
- run: python3 -m compileall app
|
- run: python3 -m compileall app
|
||||||
|
- run: python3 -m unittest discover -s tests
|
||||||
|
|||||||
67
internal/pf/store_test.go
Normal file
67
internal/pf/store_test.go
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
package pf
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func strPtr(v string) *string {
|
||||||
|
return &v
|
||||||
|
}
|
||||||
|
|
||||||
|
func int64Ptr(v int64) *int64 {
|
||||||
|
return &v
|
||||||
|
}
|
||||||
|
|
||||||
|
func float64Ptr(v float64) *float64 {
|
||||||
|
return &v
|
||||||
|
}
|
||||||
|
|
||||||
|
func validProjectPayload() ProjectPayload {
|
||||||
|
return ProjectPayload{
|
||||||
|
Title: "Full Park View",
|
||||||
|
DealType: "sale",
|
||||||
|
OurPrice: float64Ptr(2500000),
|
||||||
|
DLDPermit: strPtr("7140504127"),
|
||||||
|
Building: strPtr("Harbour Gate Tower 2"),
|
||||||
|
Bedrooms: int64Ptr(2),
|
||||||
|
SizeSqft: float64Ptr(1081),
|
||||||
|
OurURL: strPtr(
|
||||||
|
"https://www.propertyfinder.ae/en/plp/buy/apartment-for-sale-dubai-dubai-creek-harbour-the-lagoons-harbour-gate-harbour-gate-tower-2-86176216.html",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateProjectRequiredAcceptsConcretePropertyFinderListingURL(t *testing.T) {
|
||||||
|
payload := validProjectPayload()
|
||||||
|
|
||||||
|
if err := validateProjectRequired(payload); err != nil {
|
||||||
|
t.Fatalf("validateProjectRequired() returned unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateProjectRequiredRejectsSearchPageAsOurURL(t *testing.T) {
|
||||||
|
payload := validProjectPayload()
|
||||||
|
payload.OurURL = strPtr("https://www.propertyfinder.ae/en/search?c=1&l=12345")
|
||||||
|
|
||||||
|
err := validateProjectRequired(payload)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("validateProjectRequired() accepted a search page as our_url")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "concrete PropertyFinder listing URL") {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateProjectRequiredRejectsListingLikeURLWithoutID(t *testing.T) {
|
||||||
|
payload := validProjectPayload()
|
||||||
|
payload.OurURL = strPtr("https://www.propertyfinder.ae/en/plp/buy/apartment-for-sale-dubai-dubai-creek-harbour.html")
|
||||||
|
|
||||||
|
err := validateProjectRequired(payload)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("validateProjectRequired() accepted a listing-like URL without listing id")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "concrete PropertyFinder listing URL") {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
147
tests/test_monitoring_rules.py
Normal file
147
tests/test_monitoring_rules.py
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from app.db import Base
|
||||||
|
from app.models import CompetitorListing, DealType, Employee, ListingStatus, Project, Source
|
||||||
|
from app.scrapers.base import ScrapedListing
|
||||||
|
from app.scrapers.propertyfinder import PropertyFinderScraper
|
||||||
|
from app.services import monitor
|
||||||
|
|
||||||
|
|
||||||
|
PF_OWN_URL = (
|
||||||
|
"https://www.propertyfinder.ae/en/plp/buy/apartment-for-sale-dubai-dubai-creek-harbour-"
|
||||||
|
"the-lagoons-harbour-gate-harbour-gate-tower-2-86176216.html"
|
||||||
|
)
|
||||||
|
PF_COMPETITOR_URL = (
|
||||||
|
"https://www.propertyfinder.ae/en/plp/buy/apartment-for-sale-dubai-dubai-creek-harbour-"
|
||||||
|
"the-lagoons-harbour-gate-harbour-gate-tower-2-86170000.html"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _listing(external_id: str, permit: str | None, url: str = PF_COMPETITOR_URL) -> ScrapedListing:
|
||||||
|
return ScrapedListing(
|
||||||
|
source="propertyfinder",
|
||||||
|
external_id=external_id,
|
||||||
|
url=url,
|
||||||
|
title=f"Listing {external_id}",
|
||||||
|
price=2_500_000,
|
||||||
|
currency="AED",
|
||||||
|
permit_number=permit,
|
||||||
|
agent_name="Agent",
|
||||||
|
agency_name="Agency",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MonitoringRulesTest(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
engine = create_engine("sqlite:///:memory:", future=True)
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
self.Session = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
|
||||||
|
self.db = self.Session()
|
||||||
|
|
||||||
|
owner = Employee(name="Agent", portal_user_id="agent-1", tg_chat_id="100")
|
||||||
|
self.db.add(owner)
|
||||||
|
self.db.flush()
|
||||||
|
self.project = Project(
|
||||||
|
title="Full Park View",
|
||||||
|
deal_type=DealType.SALE,
|
||||||
|
our_price=2_500_000,
|
||||||
|
dld_permit="7140504127",
|
||||||
|
building="Harbour Gate Tower 2",
|
||||||
|
bedrooms=2,
|
||||||
|
size_sqft=1081,
|
||||||
|
our_url=PF_OWN_URL,
|
||||||
|
owner_id=owner.id,
|
||||||
|
)
|
||||||
|
self.db.add(self.project)
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.db.close()
|
||||||
|
|
||||||
|
def test_propertyfinder_rejects_search_pages(self) -> None:
|
||||||
|
scraper = PropertyFinderScraper()
|
||||||
|
|
||||||
|
self.assertFalse(scraper.is_listing_url("https://www.propertyfinder.ae/en/search?c=1&l=12345"))
|
||||||
|
self.assertIsNone(scraper.fetch_listing("https://www.propertyfinder.ae/en/search?c=1&l=12345"))
|
||||||
|
|
||||||
|
@patch.object(monitor.PF, "get_permit", side_effect=["7140504127"])
|
||||||
|
@patch.object(
|
||||||
|
monitor.PF,
|
||||||
|
"search_similar",
|
||||||
|
return_value=[
|
||||||
|
_listing("86176216", None, url=PF_OWN_URL),
|
||||||
|
_listing("86170000", None, url=PF_COMPETITOR_URL),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_suggest_similar_excludes_own_listing(self, _search, _permit) -> None:
|
||||||
|
suggestions = monitor.suggest_similar(self.project, our_permit="7140504127")
|
||||||
|
|
||||||
|
self.assertEqual(["86170000"], [item.external_id for item in suggestions["propertyfinder"]])
|
||||||
|
|
||||||
|
@patch.object(
|
||||||
|
monitor,
|
||||||
|
"suggest_similar",
|
||||||
|
return_value={
|
||||||
|
"propertyfinder": [
|
||||||
|
_listing("86170000", "7140504127"),
|
||||||
|
_listing("86170001", "DIFFERENT"),
|
||||||
|
],
|
||||||
|
"bayut": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def test_sync_permit_competitors_adds_only_exact_permit_matches(self, _suggest) -> None:
|
||||||
|
changes, suggestions, permit = monitor.sync_permit_competitors(self.db, self.project)
|
||||||
|
|
||||||
|
listings = self.db.query(CompetitorListing).order_by(CompetitorListing.external_id).all()
|
||||||
|
self.assertEqual("7140504127", permit)
|
||||||
|
self.assertEqual(1, len(listings))
|
||||||
|
self.assertEqual("86170000", listings[0].external_id)
|
||||||
|
self.assertTrue(listings[0].auto_discovered)
|
||||||
|
self.assertEqual(["86170001"], [item.external_id for item in suggestions["propertyfinder"]])
|
||||||
|
self.assertEqual(1, len(changes))
|
||||||
|
|
||||||
|
@patch.object(monitor, "suggest_similar", return_value={"propertyfinder": [], "bayut": []})
|
||||||
|
def test_auto_permit_listing_is_removed_only_after_three_misses(self, _suggest) -> None:
|
||||||
|
listing = CompetitorListing(
|
||||||
|
project_id=self.project.id,
|
||||||
|
source=Source.PROPERTYFINDER,
|
||||||
|
external_id="86170000",
|
||||||
|
url=PF_COMPETITOR_URL,
|
||||||
|
title="Competitor",
|
||||||
|
permit_number="7140504127",
|
||||||
|
auto_discovered=True,
|
||||||
|
permit_missing_checks=0,
|
||||||
|
current_price=2_500_000,
|
||||||
|
currency="AED",
|
||||||
|
status=ListingStatus.ACTIVE,
|
||||||
|
)
|
||||||
|
self.db.add(listing)
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
changes, _, _ = monitor.sync_permit_competitors(self.db, self.project)
|
||||||
|
self.db.flush()
|
||||||
|
self.assertEqual([], changes)
|
||||||
|
self.assertEqual(1, self.db.query(CompetitorListing).count())
|
||||||
|
self.assertEqual(1, listing.permit_missing_checks)
|
||||||
|
|
||||||
|
changes, _, _ = monitor.sync_permit_competitors(self.db, self.project)
|
||||||
|
self.db.flush()
|
||||||
|
self.assertEqual([], changes)
|
||||||
|
self.assertEqual(1, self.db.query(CompetitorListing).count())
|
||||||
|
self.assertEqual(2, listing.permit_missing_checks)
|
||||||
|
|
||||||
|
changes, _, _ = monitor.sync_permit_competitors(self.db, self.project)
|
||||||
|
self.db.flush()
|
||||||
|
self.assertEqual(1, len(changes))
|
||||||
|
self.assertEqual(0, self.db.query(CompetitorListing).count())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user