From cb8e290d8fb70893b77ba8c67c1ee9812dd95ee7 Mon Sep 17 00:00:00 2001 From: Grendgi Date: Wed, 17 Jun 2026 17:12:49 +0300 Subject: [PATCH] test: cover propertyfinder matching rules --- .gitea/workflows/ci.yml | 2 + internal/pf/store_test.go | 67 +++++++++++++++ tests/test_monitoring_rules.py | 147 +++++++++++++++++++++++++++++++++ 3 files changed, 216 insertions(+) create mode 100644 internal/pf/store_test.go create mode 100644 tests/test_monitoring_rules.py diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 74365aa..0d52136 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -32,4 +32,6 @@ jobs: needs: hygiene steps: - uses: actions/checkout@v4 + - run: python3 -m pip install -r requirements.txt - run: python3 -m compileall app + - run: python3 -m unittest discover -s tests diff --git a/internal/pf/store_test.go b/internal/pf/store_test.go new file mode 100644 index 0000000..036bf9c --- /dev/null +++ b/internal/pf/store_test.go @@ -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) + } +} diff --git a/tests/test_monitoring_rules.py b/tests/test_monitoring_rules.py new file mode 100644 index 0000000..9600be5 --- /dev/null +++ b/tests/test_monitoring_rules.py @@ -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()