diff --git a/.env.example b/.env.example
index 9589120..0886a25 100644
--- a/.env.example
+++ b/.env.example
@@ -194,6 +194,15 @@ CAPTCHA_CHECK_INTERVAL=30
# Maximum time to wait for CAPTCHA resolution before aborting (in minutes)
CAPTCHA_TIMEOUT_MINUTES=30
+# Wait time after page refresh when checking CAPTCHA status (in seconds)
+CAPTCHA_PAGE_LOAD_WAIT=2
+
+# ============================================================================
+# SHUTDOWN HANDLING
+# ============================================================================
+# How often to check for shutdown signal during retry delays (in seconds)
+SHUTDOWN_CHECK_INTERVAL=1
+
# ============================================================================
# NOTIFICATION LOGIC
# ============================================================================
diff --git a/.github/scripts/parse_test_results.py b/.github/scripts/parse_test_results.py
new file mode 100644
index 0000000..db3bfe4
--- /dev/null
+++ b/.github/scripts/parse_test_results.py
@@ -0,0 +1,29 @@
+#!/usr/bin/env python3
+"""Parse pytest JUnit XML results for GitHub Actions summary."""
+
+import xml.etree.ElementTree as ET
+import sys
+
+def main():
+ try:
+ tree = ET.parse('pytest-results.xml')
+ root = tree.getroot()
+
+ tests = root.attrib.get('tests', '0')
+ skipped = root.attrib.get('skipped', '0')
+ failures = root.attrib.get('failures', '0')
+ errors = root.attrib.get('errors', '0')
+ time = float(root.attrib.get('time', '0'))
+
+ print("| Tests | Skipped | Failures | Errors | Time |")
+ print("|-------|---------|----------|--------|------|")
+ print(f"| {tests} | {skipped} ๐ฆ | {failures} โ | {errors} ๐ฅ | {time:.2f}s โฑ๏ธ |")
+ print()
+
+ except FileNotFoundError:
+ print("โ ๏ธ Test results file not found")
+ except Exception as e:
+ print(f"โ ๏ธ Could not parse test results: {e}")
+
+if __name__ == '__main__':
+ main()
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
new file mode 100644
index 0000000..6f6e822
--- /dev/null
+++ b/.github/workflows/lint.yml
@@ -0,0 +1,55 @@
+name: Code Quality
+
+on:
+ push:
+ branches: [ main, master, develop ]
+ pull_request:
+ branches: [ main, master, develop ]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ Linting:
+ name: Linting & Type Checking
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+
+ steps:
+ - uses: actions/checkout@v3
+
+ - name: Set up Python
+ uses: actions/setup-python@v4
+ with:
+ python-version: '3.11'
+ cache: 'pip'
+
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install flake8 pylint mypy ruff
+ pip install -r requirements.txt
+
+ - name: Run ruff (fast linter)
+ continue-on-error: true
+ run: |
+ ruff check app/ --output-format=github
+
+ - name: Run flake8
+ run: |
+ # Stop the build if there are Python syntax errors or undefined names
+ flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
+ # Exit-zero treats all errors as warnings
+ flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics \
+ --exclude=.git,__pycache__,venv,env,.venv,.mypy_cache,.pytest_cache,build,dist
+
+ - name: Run pylint
+ continue-on-error: true
+ run: |
+ pylint app/ --exit-zero --output-format=colorized --score=yes
+
+ - name: Run mypy
+ continue-on-error: true
+ run: |
+ mypy app/ --ignore-missing-imports --no-strict-optional --pretty
diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml
deleted file mode 100644
index 824aa76..0000000
--- a/.github/workflows/python-app.yml
+++ /dev/null
@@ -1,142 +0,0 @@
-name: Python Tests
-
-on:
- push:
- branches: [ main, master, develop ]
- pull_request:
- branches: [ main, master, develop ]
-
-jobs:
- test:
- runs-on: ubuntu-latest
-
- strategy:
- matrix:
- python-version: ['3.9', '3.10', '3.11']
-
- steps:
- - uses: actions/checkout@v3
-
- - name: Set up Python ${{ matrix.python-version }}
- uses: actions/setup-python@v4
- with:
- python-version: ${{ matrix.python-version }}
-
- - name: Cache pip packages
- uses: actions/cache@v3
- with:
- path: ~/.cache/pip
- key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
- restore-keys: |
- ${{ runner.os }}-pip-
-
- - name: Install system dependencies
- run: |
- sudo apt-get update
- sudo apt-get install -y libxml2-dev libxslt-dev python3-dev libjpeg-dev zlib1g-dev
-
- - name: Install dependencies
- run: |
- python -m pip install --upgrade pip
- pip install -r requirements.txt
-
- - name: Set PYTHONPATH
- run: |
- echo "PYTHONPATH=$PYTHONPATH:$(pwd)/app" >> $GITHUB_ENV
-
- - name: Run tests with pytest
- env:
- MOCK_BROWSER: "true"
- DATABASE_URL: "sqlite:///:memory:"
- run: |
- pytest tests/ \
- --verbose \
- --cov=app \
- --cov-report=term-missing \
- --cov-report=xml \
- --cov-report=html \
- --cov-fail-under=35
-
- - name: Upload coverage reports
- uses: codecov/codecov-action@v3
- if: matrix.python-version == '3.11'
- with:
- file: ./coverage.xml
- flags: unittests
- name: codecov-umbrella
- fail_ci_if_error: false
-
- - name: Archive coverage HTML report
- uses: actions/upload-artifact@v4
- if: matrix.python-version == '3.11'
- with:
- name: coverage-report
- path: htmlcov/
- retention-days: 30
-
- - name: Generate coverage badge
- if: matrix.python-version == '3.11' && github.event_name == 'push'
- run: |
- COVERAGE=$(python -c "import xml.etree.ElementTree as ET; tree = ET.parse('coverage.xml'); root = tree.getroot(); print(root.attrib['line-rate'])")
- COVERAGE_PCT=$(python -c "print(f'{float('$COVERAGE') * 100:.1f}')")
- echo "Coverage: ${COVERAGE_PCT}%"
- echo "COVERAGE_PCT=${COVERAGE_PCT}" >> $GITHUB_ENV
-
- - name: Comment PR with coverage
- uses: actions/github-script@v7
- if: matrix.python-version == '3.11' && github.event_name == 'pull_request'
- with:
- github-token: ${{ secrets.GITHUB_TOKEN }}
- script: |
- const fs = require('fs');
- const coverage = fs.readFileSync('coverage.xml', 'utf8');
- const match = coverage.match(/line-rate="([\d.]+)"/);
- const coveragePct = match ? (parseFloat(match[1]) * 100).toFixed(1) : 'N/A';
-
- const comment = `## ๐งช Test Results
-
- **Coverage:** ${coveragePct}%
-
-
- ๐ View detailed coverage report
-
- Download the \`coverage-report\` artifact from the workflow run to view the full HTML report.
-
-
-
- ${coveragePct >= 35 ? 'โ
Coverage threshold met (35%)' : 'โ Coverage below threshold (35%)'}
- `;
-
- github.rest.issues.createComment({
- issue_number: context.issue.number,
- owner: context.repo.owner,
- repo: context.repo.repo,
- body: comment
- });
-
- lint:
- runs-on: ubuntu-latest
-
- steps:
- - uses: actions/checkout@v3
-
- - name: Set up Python
- uses: actions/setup-python@v4
- with:
- python-version: '3.11'
-
- - name: Install linting tools
- run: |
- python -m pip install --upgrade pip
- pip install pylint mypy
- pip install -r requirements.txt
-
- - name: Run pylint
- continue-on-error: true
- run: |
- pylint app/ --exit-zero --output-format=text
-
- - name: Run mypy
- continue-on-error: true
- run: |
- mypy app/ --ignore-missing-imports --no-strict-optional
diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml
new file mode 100644
index 0000000..c462109
--- /dev/null
+++ b/.github/workflows/security.yml
@@ -0,0 +1,53 @@
+name: Security
+
+on:
+ push:
+ branches: [ main, master, develop ]
+ pull_request:
+ branches: [ main, master, develop ]
+ workflow_dispatch:
+ schedule:
+ - cron: '0 0 * * 0' # Run weekly on Sundays
+
+permissions:
+ contents: read
+
+jobs:
+ Security:
+ name: Security Scan
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+
+ steps:
+ - uses: actions/checkout@v3
+
+ - name: Set up Python
+ uses: actions/setup-python@v4
+ with:
+ python-version: '3.11'
+ cache: 'pip'
+
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install safety bandit
+
+ - name: Check for security vulnerabilities in dependencies
+ continue-on-error: true
+ run: |
+ safety check --json || true
+
+ - name: Run bandit security scan
+ continue-on-error: true
+ run: |
+ bandit -r app/ -f json -o bandit-report.json || true
+ bandit -r app/ -f screen
+
+ - name: Upload security reports
+ uses: actions/upload-artifact@v4
+ if: always()
+ with:
+ name: security-reports
+ path: |
+ bandit-report.json
+ retention-days: 30
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..efdcaf8
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,209 @@
+name: Tests
+
+on:
+ push:
+ branches: [ main, master, develop ]
+ pull_request:
+ branches: [ main, master, develop ]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ pull-requests: write
+ issues: write
+
+jobs:
+ Tests:
+ name: Python ${{ matrix.python-version }}
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+
+ strategy:
+ fail-fast: false
+ matrix:
+ python-version: ['3.9', '3.10', '3.11', '3.12']
+
+ steps:
+ - uses: actions/checkout@v3
+
+ - name: Set up Python ${{ matrix.python-version }}
+ uses: actions/setup-python@v4
+ with:
+ python-version: ${{ matrix.python-version }}
+ cache: 'pip'
+
+ - name: Install system dependencies
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y libxml2-dev libxslt-dev python3-dev libjpeg-dev zlib1g-dev
+
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip setuptools wheel
+ pip install -r requirements.txt
+
+ - name: Set PYTHONPATH
+ run: |
+ echo "PYTHONPATH=$PYTHONPATH:$(pwd)/app" >> $GITHUB_ENV
+
+ - name: Run unit tests
+ env:
+ MOCK_BROWSER: "true"
+ DATABASE_URL: "sqlite:///:memory:"
+ run: |
+ pytest tests/unit/ \
+ --verbose \
+ --cov=app \
+ --cov-report=term-missing \
+ --cov-report=xml \
+ --cov-report=html \
+ --cov-fail-under=60 \
+ --junitxml=pytest-results.xml \
+ -m "not slow"
+
+ - name: Run integration tests
+ if: matrix.python-version == '3.11'
+ env:
+ MOCK_BROWSER: "true"
+ DATABASE_URL: "sqlite:///:memory:"
+ run: |
+ pytest tests/ \
+ --verbose \
+ -m "integration or slow" \
+ --maxfail=3
+
+ - name: Upload coverage to Codecov
+ uses: codecov/codecov-action@v4
+ if: matrix.python-version == '3.11'
+ with:
+ file: ./coverage.xml
+ flags: unittests
+ name: codecov-umbrella
+ fail_ci_if_error: false
+ token: ${{ secrets.CODECOV_TOKEN }}
+
+ - name: Archive coverage HTML report
+ uses: actions/upload-artifact@v4
+ if: matrix.python-version == '3.11'
+ with:
+ name: coverage-report-py${{ matrix.python-version }}
+ path: htmlcov/
+ retention-days: 30
+
+ - name: Pytest coverage comment
+ uses: MishaKav/pytest-coverage-comment@main
+ if: matrix.python-version == '3.11' && github.event_name == 'pull_request'
+ continue-on-error: true
+ with:
+ pytest-coverage-path: ./coverage.xml
+ junitxml-path: ./pytest-results.xml
+ title: Coverage Report
+ badge-title: Coverage
+ hide-badge: false
+ hide-report: false
+ create-new-comment: false
+ hide-comment: false
+ report-only-changed-files: false
+ remove-link-from-badge: false
+
+ - name: Generate test summary
+ if: always()
+ run: |
+ echo "## Test Results - Python ${{ matrix.python-version }}" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+
+ # Parse test results from JUnit XML
+ if [ -f pytest-results.xml ]; then
+ python3 .github/scripts/parse_test_results.py >> $GITHUB_STEP_SUMMARY
+ fi
+
+ # Add coverage info
+ if [ -f coverage.xml ]; then
+ COVERAGE=$(python -c "import xml.etree.ElementTree as ET; tree = ET.parse('coverage.xml'); root = tree.getroot(); print(f\"{float(root.attrib['line-rate']) * 100:.1f}\")")
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "**Coverage:** ${COVERAGE}%" >> $GITHUB_STEP_SUMMARY
+ fi
+
+ - name: Comment PR with coverage
+ uses: actions/github-script@v7
+ if: matrix.python-version == '3.11' && github.event_name == 'pull_request'
+ continue-on-error: true
+ with:
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ script: |
+ const fs = require('fs');
+ const xml2js = require('xml2js');
+
+ try {
+ // Parse coverage data
+ const coverage = fs.readFileSync('coverage.xml', 'utf8');
+ const match = coverage.match(/line-rate="([\d.]+)"/);
+ const coveragePct = match ? (parseFloat(match[1]) * 100).toFixed(0) : 'N/A';
+
+ // Parse JUnit XML for test results
+ const junitXml = fs.readFileSync('pytest-results.xml', 'utf8');
+ const parser = new xml2js.Parser();
+ let testData = { tests: 0, skipped: 0, failures: 0, errors: 0, time: '0.0' };
+
+ parser.parseString(junitXml, (err, result) => {
+ if (!err && result.testsuite) {
+ const suite = result.testsuite.$;
+ testData = {
+ tests: suite.tests || 0,
+ skipped: suite.skipped || 0,
+ failures: suite.failures || 0,
+ errors: suite.errors || 0,
+ time: parseFloat(suite.time || 0).toFixed(2)
+ };
+ }
+ });
+
+ // Determine coverage badge color
+ const badgeColor = coveragePct >= 70 ? 'brightgreen' : coveragePct >= 50 ? 'yellow' : 'red';
+ const thresholdMet = coveragePct >= 60 ? 'โ
Coverage threshold met (60%)' : 'โ ๏ธ Coverage below threshold (60%)';
+
+ const comment = [
+ '## ๐งช Test Results',
+ '',
+ ``,
+ '',
+ '### ๐ Test Summary',
+ '',
+ '| Tests | Skipped | Failures | Errors | Time |',
+ '|-------|---------|----------|--------|------|',
+ `| ${testData.tests} | ${testData.skipped} ๐ฆ | ${testData.failures} โ | ${testData.errors} ๐ฅ | ${testData.time}s โฑ๏ธ |`,
+ '',
+ '',
+ '๐ View detailed coverage report
',
+ '',
+ 'Download the `coverage-report-py3.11` artifact from the workflow run to view the full HTML report.',
+ '',
+ ' ',
+ '',
+ thresholdMet
+ ].join('\n');
+
+ await github.rest.issues.createComment({
+ issue_number: context.issue.number,
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ body: comment
+ });
+
+ console.log('Successfully posted coverage comment to PR');
+ } catch (error) {
+ console.log('Failed to post PR comment:', error.message);
+ console.log('This is expected for PRs from forks due to security restrictions');
+ }
+
+ - name: Upload logs on failure
+ uses: actions/upload-artifact@v4
+ if: failure()
+ with:
+ name: test-logs-py${{ matrix.python-version }}
+ path: |
+ *.log
+ debug_output/
+ pytest-results.xml
+ retention-days: 7
+ if-no-files-found: ignore
diff --git a/app/core/config.py b/app/core/config.py
index 03f1275..abfaf44 100644
--- a/app/core/config.py
+++ b/app/core/config.py
@@ -69,10 +69,20 @@ class Settings(BaseSettings):
# Captcha Logic
captcha_check_interval: int = Field(default=30, env="CAPTCHA_CHECK_INTERVAL")
captcha_timeout_minutes: int = Field(default=30, env="CAPTCHA_TIMEOUT_MINUTES")
+ captcha_page_load_wait: int = Field(default=2, env="CAPTCHA_PAGE_LOAD_WAIT")
+
+ # Shutdown Logic
+ shutdown_check_interval: int = Field(default=1, env="SHUTDOWN_CHECK_INTERVAL")
# Deal Score Logic
min_deal_score_for_noti: float = Field(default=80, env="MIN_DEAL_SCORE_FOR_NOTI")
+ # Deal Score Weights (total should be 100)
+ deal_score_weight_price: float = Field(default=40.0, env="DEAL_SCORE_WEIGHT_PRICE")
+ deal_score_weight_features: float = Field(default=30.0, env="DEAL_SCORE_WEIGHT_FEATURES")
+ deal_score_weight_recency: float = Field(default=15.0, env="DEAL_SCORE_WEIGHT_RECENCY")
+ deal_score_weight_price_trend: float = Field(default=15.0, env="DEAL_SCORE_WEIGHT_PRICE_TREND")
+
# Logging
log_level: str = Field(default="INFO", env="LOG_LEVEL")
log_file: str = Field(default="real_estate_monitor.log", env="LOG_FILE")
diff --git a/app/core/deal_score.py b/app/core/deal_score.py
index f9b6e66..9b65209 100644
--- a/app/core/deal_score.py
+++ b/app/core/deal_score.py
@@ -17,24 +17,24 @@ def calculate_score(self, listing: Listing) -> float:
"""
Calculate deal score (0-100) for a listing
- Scoring breakdown:
- - Price competitiveness: 0-40 points
- - Features match: 0-30 points
- - Recency/freshness: 0-15 points
- - Price trend: 0-15 points
+ Scoring breakdown (configurable via settings):
+ - Price competitiveness: 0-{price_weight} points
+ - Features match: 0-{features_weight} points
+ - Recency/freshness: 0-{recency_weight} points
+ - Price trend: 0-{trend_weight} points
"""
score = 0.0
- # 1. Price Competitiveness (0-40 points)
+ # 1. Price Competitiveness
score += self._score_price_competitiveness(listing)
- # 2. Features Match (0-30 points)
+ # 2. Features Match
score += self._score_features(listing)
- # 3. Recency (0-15 points)
+ # 3. Recency
score += self._score_recency(listing)
- # 4. Price Trend (0-15 points)
+ # 4. Price Trend
score += self._score_price_trend(listing)
return min(100.0, max(0.0, score))
@@ -44,6 +44,8 @@ def _score_price_competitiveness(self, listing: Listing) -> float:
if not listing.price_per_sqm or listing.price_per_sqm <= 0:
return 0.0
+ max_score = self.settings.deal_score_weight_price
+
# Get neighborhood stats
stats = self.db.query(NeighborhoodStats).filter(
NeighborhoodStats.city == listing.city,
@@ -51,33 +53,33 @@ def _score_price_competitiveness(self, listing: Listing) -> float:
).first()
if not stats or not stats.avg_price_per_sqm:
- # No data, give neutral score
- return 20.0
+ # No data, give neutral score (50% of max)
+ return max_score * 0.5
# Calculate percentage difference
avg_price = stats.avg_price_per_sqm
price_ratio = listing.price_per_sqm / avg_price
- # Score based on how much below average
+ # Score based on how much below average (percentage of max_score)
if price_ratio <= 0.7: # 30% below average
- return 40.0
+ return max_score * 1.0
elif price_ratio <= 0.8: # 20% below average
- return 35.0
+ return max_score * 0.875
elif price_ratio <= 0.9: # 10% below average
- return 30.0
+ return max_score * 0.75
elif price_ratio <= 1.0: # At or slightly below average
- return 25.0
+ return max_score * 0.625
elif price_ratio <= 1.1: # 10% above average
- return 15.0
+ return max_score * 0.375
elif price_ratio <= 1.2: # 20% above average
- return 10.0
+ return max_score * 0.25
else: # More than 20% above average
- return 5.0
+ return max_score * 0.125
def _score_features(self, listing: Listing) -> float:
"""Score based on matching user preferences"""
score = 0.0
- max_score = 30.0
+ max_score = self.settings.deal_score_weight_features
features = []
@@ -93,11 +95,10 @@ def _score_features(self, listing: Listing) -> float:
if self.settings.prefer_elevator:
features.append(('elevator', listing.has_elevator, 7.0))
- # Mamad - ืืืกืฃ ืืช ืื
+ # Mamad
if self.settings.prefer_mamad:
features.append(('mamad', listing.has_mamad, 8.0))
-
# Top floors preference
if self.settings.prefer_top_floors and listing.floor and listing.total_floors:
is_top_half = listing.floor >= (listing.total_floors / 2)
@@ -114,57 +115,62 @@ def _score_features(self, listing: Listing) -> float:
def _score_recency(self, listing: Listing) -> float:
"""Score based on how fresh the listing is"""
+ max_score = self.settings.deal_score_weight_recency
+
if not listing.first_seen:
- return 15.0 # New listing, give max score
+ return max_score # New listing, give max score
days_old = (datetime.utcnow() - listing.first_seen).days
if days_old == 0: # Today
- return 15.0
+ return max_score * 1.0
elif days_old <= 2: # 1-2 days
- return 12.0
+ return max_score * 0.8
elif days_old <= 5: # 3-5 days
- return 9.0
+ return max_score * 0.6
elif days_old <= 10: # 6-10 days
- return 6.0
+ return max_score * 0.4
elif days_old <= 20: # 11-20 days
- return 3.0
+ return max_score * 0.2
else: # Over 20 days
- return 1.0
+ return max_score * 0.067
def _score_price_trend(self, listing: Listing) -> float:
"""Score based on price changes"""
+ max_score = self.settings.deal_score_weight_price_trend
+ neutral_score = max_score * 0.333 # 1/3 of max for neutral
+
if not listing.price_history or len(listing.price_history) < 2:
- return 5.0 # Neutral score for no history
+ return neutral_score # Neutral score for no history
# Get most recent price changes
sorted_history = sorted(listing.price_history, key=lambda x: x.timestamp, reverse=True)
if len(sorted_history) < 2:
- return 5.0
+ return neutral_score
current_price = sorted_history[0].price
previous_price = sorted_history[1].price
if not current_price or not previous_price or previous_price <= 0:
- return 5.0
+ return neutral_score
# Calculate price change percentage
price_change_pct = ((current_price - previous_price) / previous_price) * 100
# Score based on price drops
if price_change_pct <= -10: # 10%+ drop
- return 15.0
+ return max_score * 1.0
elif price_change_pct <= -5: # 5-10% drop
- return 12.0
+ return max_score * 0.8
elif price_change_pct <= -2: # 2-5% drop
- return 9.0
+ return max_score * 0.6
elif price_change_pct < 0: # Any drop
- return 7.0
+ return max_score * 0.467
elif price_change_pct == 0: # No change
- return 5.0
+ return neutral_score
else: # Price increase
- return 2.0
+ return max_score * 0.133
def get_price_drop_percentage(self, listing: Listing) -> Optional[float]:
"""Get percentage of price drop if any"""
diff --git a/app/scrapers/base_scraper.py b/app/scrapers/base_scraper.py
index 98e7072..36e02c4 100644
--- a/app/scrapers/base_scraper.py
+++ b/app/scrapers/base_scraper.py
@@ -69,13 +69,19 @@ def is_timeout(self) -> bool:
class BaseScraper(ABC):
"""Base class for all scrapers with common functionality using DrissionPage"""
- def __init__(self, db_session: Session, source_name: str):
+ def __init__(self, db_session: Session, source_name: str, page: Optional[ChromiumPage] = None):
self.db = db_session
self.source_name = source_name
- self.page: Optional[ChromiumPage] = None
+ self.page: Optional[ChromiumPage] = page # Allow injection of mock page
+ self.browser_alive = True # Track browser connection status
def initialize(self):
"""Initialize browser by connecting to existing Chrome instance on debug port"""
+ # If page was injected (e.g., for testing), skip initialization
+ if self.page is not None:
+ logger.info(f"[{self.source_name}] Using injected page (likely for testing)")
+ return
+
try:
# Connect to existing Chrome instance on the configured debug port
debug_address = f"127.0.0.1:{settings.chrome_debug_port}"
@@ -229,6 +235,55 @@ def _save_cookies(self):
except Exception as e:
logger.warning(f"Failed to save cookies for {self.source_name}: {e}")
+ def _check_browser_connection(self, error: Exception) -> bool:
+ """
+ Check if error is due to browser disconnection.
+ Returns True if browser is disconnected, False otherwise.
+ """
+ error_str = str(error).lower()
+ # Check for Chinese disconnection message or common connection errors
+ disconnection_indicators = [
+ "ไธ้กต้ข็่ฟๆฅๅทฒๆญๅผ", # Chinese: "Connection to page has been disconnected"
+ "ๆญๅผ", # Chinese: "disconnected"
+ "disconnected",
+ "connection lost",
+ "target closed",
+ "session not created",
+ "cannot connect to",
+ "connection refused",
+ "connection reset",
+ ]
+
+ for indicator in disconnection_indicators:
+ if indicator in error_str:
+ logger.error(
+ f"[{self.source_name}] โ Browser connection lost. Stopping scraper run."
+ )
+ self.browser_alive = False
+ return True
+
+ return False
+
+ def _is_browser_alive(self) -> bool:
+ """
+ Check if browser connection is still alive.
+ Returns True if browser is connected, False otherwise.
+ """
+ if not self.page:
+ return False
+
+ try:
+ # Try to check if page is alive using a simple property access
+ _ = self.page.title
+ return True
+ except Exception as e:
+ # Check if this is a disconnection error
+ if self._check_browser_connection(e):
+ return False
+ # Other errors don't necessarily mean disconnection
+ logger.debug(f"[{self.source_name}] Error checking browser status: {e}")
+ return True # Assume alive unless proven disconnected
+
def random_delay(self, min_seconds: float = 1.0, max_seconds: float = 3.0):
"""Add random delay to appear human-like"""
delay = random.uniform(min_seconds, max_seconds)
@@ -499,7 +554,7 @@ def _handle_anti_bot_protection(self):
# Refresh page content and check again
try:
self.page.refresh()
- time.sleep(2) # Wait for page to load
+ time.sleep(settings.captcha_page_load_wait) # Wait for page to load
# Re-check using _check_for_captcha() method
if not self._check_for_captcha():
@@ -592,6 +647,13 @@ def scrape_with_retry(self) -> List[Dict]:
logger.debug(f"[Scraper Retry] Initializing browser, source: {source}")
self.scraper.initialize()
+ # Check if browser is alive before starting scrape
+ if not self.scraper._is_browser_alive():
+ logger.error(f"[Scraper Retry] โ Browser not alive, aborting scrape for {source}")
+ error_msg = "Browser connection not available"
+ self.scraper.update_scraping_state(success=False, error_msg=error_msg)
+ return []
+
logger.info(f"[Scraper Retry] Executing scrape, source: {source}")
listings = self.scraper.scrape()
@@ -605,6 +667,20 @@ def scrape_with_retry(self) -> List[Dict]:
except Exception as e:
last_error = e
+
+ # Check for shutdown signal immediately after exception
+ if self.shutdown_event and self.shutdown_event.is_set():
+ logger.info(f"[Scraper Retry] Shutdown detected, cancelling retries for {source}")
+ return []
+
+ # Check if this is a browser disconnection error
+ if self.scraper._check_browser_connection(e):
+ # Browser disconnected - stop retrying immediately
+ error_msg = f"Browser connection lost: {e}"
+ self.scraper.update_scraping_state(success=False, error_msg=error_msg)
+ logger.error(f"[Scraper Retry] โ Browser disconnected, aborting all retries for {source}")
+ return []
+
logger.error(f"[Scraper Retry] โ Attempt {attempt + 1} failed, source: {source}, error: {e}")
try:
@@ -614,13 +690,18 @@ def scrape_with_retry(self) -> List[Dict]:
logger.warning(f"[Scraper Retry] Cleanup failed, source: {source}, error: {cleanup_error}")
if attempt < self.max_retries - 1:
+ # Check for shutdown before initiating retry delay
+ if self.shutdown_event and self.shutdown_event.is_set():
+ logger.info(f"[Scraper Retry] Shutdown signal received, aborting retry for {source}")
+ return []
+
# Check for shutdown during retry delay
logger.info(f"[Scraper Retry] Retrying in {self.retry_delay} seconds, source: {source}")
for _ in range(self.retry_delay):
if self.shutdown_event and self.shutdown_event.is_set():
logger.info(f"[Scraper Retry] Shutdown signal received during retry delay for {source}")
return []
- time.sleep(1)
+ time.sleep(settings.shutdown_check_interval)
# All retries failed
error_msg = f"Failed after {self.max_retries} attempts: {last_error}"
diff --git a/app/scrapers/yad2_scraper.py b/app/scrapers/yad2_scraper.py
index 3f8bc1e..072e5ef 100644
--- a/app/scrapers/yad2_scraper.py
+++ b/app/scrapers/yad2_scraper.py
@@ -262,8 +262,8 @@ def _extract_number(self, text: str) -> Optional[float]:
def _extract_rooms(self, text: str) -> Optional[float]:
"""Extract number of rooms"""
- # Look for patterns like "3 ืืืจืื" or "3.5 ืื'"
- match = re.search(r'(\d+\.?\d*)\s*(?:ืืืจืื|ืื\'|rooms)', text)
+ # Look for patterns like "3 ืืืจืื", "3.5 ืื'", or "5 ืื"
+ match = re.search(r'(\d+\.?\d*)\s*(?:ืืืจืื|ืื\'|ืื|rooms)', text)
if match:
try:
return float(match.group(1))
diff --git a/app/utils/phone_normalizer.py b/app/utils/phone_normalizer.py
index 24abc20..46d628b 100644
--- a/app/utils/phone_normalizer.py
+++ b/app/utils/phone_normalizer.py
@@ -46,8 +46,8 @@ def normalize_israeli_phone(phone: Optional[str]) -> Optional[str]:
if len(digits) > 10 and digits.startswith('0'):
digits = digits[:10]
- # Validate length (Israeli mobile/landline numbers are 10 digits)
- if len(digits) != 10:
+ # Validate length (Israeli mobile numbers are 10 digits, landlines can be 9)
+ if len(digits) not in [9, 10]:
return None
return digits
diff --git a/pytest.ini b/pytest.ini
index 79e274a..294239f 100644
--- a/pytest.ini
+++ b/pytest.ini
@@ -12,6 +12,9 @@ testpaths = tests
# Add app directory to Python path
pythonpath = . app
+# Asyncio mode
+asyncio_mode = auto
+
# Coverage options
addopts =
--verbose
@@ -29,6 +32,7 @@ markers =
scraper: Tests for scraper modules
parser: Tests for parsing logic
processor: Tests for listing processor
+ asyncio: Async tests
# Ignore patterns
norecursedirs = .git .tox dist build *.egg htmlcov
diff --git a/requirements.txt b/requirements.txt
index f233e1a..1a4b5f2 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -25,3 +25,8 @@ pytest==7.4.3
pytest-cov==4.1.0
pytest-mock==3.12.0
pytest-asyncio==0.21.1
+
+# Linting and security
+ruff==0.1.9
+safety==2.3.5
+bandit==1.7.6
diff --git a/tests/conftest.py b/tests/conftest.py
index 7970175..355f0bd 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -33,11 +33,17 @@ def test_settings():
database_url="sqlite:///:memory:",
# Search criteria
- cities="Tel Aviv,Ramat Gan",
+ cities="ืชื ืืืื,Tel Aviv,Ramat Gan",
max_price=3000000,
min_rooms=3.0,
min_size_sqm=70,
+ # Deal breakers (set to False for tests to pass)
+ exclude_ground_floor=False,
+ require_elevator_above_floor=0,
+ require_parking=False,
+ require_mamad=False,
+
# Preferences
prefer_parking=True,
prefer_balcony=True,
diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py
new file mode 100644
index 0000000..d843ec4
--- /dev/null
+++ b/tests/fixtures/__init__.py
@@ -0,0 +1,3 @@
+"""
+Test fixtures for HTML samples and test data.
+"""
diff --git a/tests/fixtures/yad2_listing_sample.html b/tests/fixtures/yad2_listing_sample.html
new file mode 100644
index 0000000..59ded7c
--- /dev/null
+++ b/tests/fixtures/yad2_listing_sample.html
@@ -0,0 +1,21 @@
+
+
+
+
+ ืืืจืช 3.5 ืืืจืื ืืืืืจื ืืชื ืืืื - ืื2
+
+
+
+
+
diff --git a/tests/unit/test_base_scraper.py b/tests/unit/test_base_scraper.py
new file mode 100644
index 0000000..7834807
--- /dev/null
+++ b/tests/unit/test_base_scraper.py
@@ -0,0 +1,434 @@
+"""
+Unit tests for base scraper
+"""
+import pytest
+from unittest.mock import Mock, patch, MagicMock
+from datetime import datetime, timedelta
+
+from app.scrapers.base_scraper import (
+ BaseScraper,
+ ScraperWithRetry,
+ CaptchaState,
+ captcha_state
+)
+from app.core.database import ScrapingState
+
+
+@pytest.fixture
+def mock_db_session():
+ """Create mock database session"""
+ return Mock()
+
+
+@pytest.fixture
+def mock_page():
+ """Create mock ChromiumPage"""
+ page = Mock()
+ page.title = "Test Page"
+ page.html = "Test"
+ page.url = "https://example.com"
+ page.cookies = Mock(return_value=[])
+ return page
+
+
+class ConcreteScraper(BaseScraper):
+ """Concrete implementation for testing"""
+
+ def scrape(self):
+ return [{"title": "Test"}]
+
+ def parse_listing(self, raw_data):
+ return raw_data
+
+
+class TestCaptchaState:
+ """Test CaptchaState singleton"""
+
+ def test_singleton(self):
+ """Test that CaptchaState is a singleton"""
+ state1 = CaptchaState()
+ state2 = CaptchaState()
+ assert state1 is state2
+
+ def test_set_waiting(self):
+ """Test setting waiting state"""
+ state = CaptchaState()
+ state.set_waiting("test_source")
+
+ assert state.is_waiting() is True
+ assert state._source == "test_source"
+ assert state._waiting_since is not None
+
+ def test_set_normal(self):
+ """Test resetting to normal state"""
+ state = CaptchaState()
+ state.set_waiting("test_source")
+ state.set_normal()
+
+ assert state.is_waiting() is False
+ assert state._source is None
+ assert state._waiting_since is None
+
+ def test_get_status(self):
+ """Test getting status"""
+ state = CaptchaState()
+ state.set_normal()
+
+ status = state.get_status()
+
+ assert status["status"] == "NORMAL"
+ assert status["waiting_since"] is None
+ assert status["source"] is None
+
+ def test_get_status_waiting(self):
+ """Test getting status when waiting"""
+ state = CaptchaState()
+ state.set_waiting("test_source")
+
+ status = state.get_status()
+
+ assert status["status"] == "WAITING_FOR_CAPTCHA"
+ assert status["source"] == "test_source"
+ assert status["waiting_since"] is not None
+
+ def test_is_timeout_false(self):
+ """Test timeout check when not timed out"""
+ state = CaptchaState()
+ state.set_waiting("test_source")
+
+ assert state.is_timeout() is False
+
+ def test_is_timeout_true(self):
+ """Test timeout check when timed out"""
+ state = CaptchaState()
+ state.set_waiting("test_source")
+ # Manually set old timestamp
+ state._waiting_since = datetime.utcnow() - timedelta(minutes=100)
+
+ with patch('app.scrapers.base_scraper.settings') as mock_settings:
+ mock_settings.captcha_timeout_minutes = 30
+ assert state.is_timeout() is True
+
+
+class TestBaseScraper:
+ """Test BaseScraper class"""
+
+ def test_init(self, mock_db_session):
+ """Test scraper initialization"""
+ scraper = ConcreteScraper(mock_db_session, "test_source")
+
+ assert scraper.db == mock_db_session
+ assert scraper.source_name == "test_source"
+ assert scraper.page is None
+
+ @patch('app.scrapers.base_scraper.ChromiumPage')
+ def test_initialize_success(self, mock_chromium_page, mock_db_session):
+ """Test successful browser initialization"""
+ mock_page = Mock()
+ mock_chromium_page.return_value = mock_page
+
+ scraper = ConcreteScraper(mock_db_session, "test_source")
+
+ with patch.object(scraper, '_inject_anti_detection_scripts'):
+ with patch.object(scraper, '_load_cookies'):
+ scraper.initialize()
+
+ assert scraper.page is not None
+
+ @patch('app.scrapers.base_scraper.ChromiumPage')
+ def test_initialize_connection_error(self, mock_chromium_page, mock_db_session):
+ """Test initialization with connection error"""
+ mock_chromium_page.side_effect = ConnectionError("Connection failed")
+
+ scraper = ConcreteScraper(mock_db_session, "test_source")
+
+ with pytest.raises(ConnectionError):
+ scraper.initialize()
+
+ def test_inject_anti_detection_scripts(self, mock_db_session, mock_page):
+ """Test injecting anti-detection scripts"""
+ scraper = ConcreteScraper(mock_db_session, "test_source")
+ scraper.page = mock_page
+
+ scraper._inject_anti_detection_scripts()
+
+ # Should call run_js multiple times
+ assert mock_page.run_js.call_count > 0
+
+ def test_inject_anti_detection_scripts_no_page(self, mock_db_session):
+ """Test injecting scripts with no page"""
+ scraper = ConcreteScraper(mock_db_session, "test_source")
+ scraper.page = None
+
+ # Should not raise error
+ scraper._inject_anti_detection_scripts()
+
+ def test_load_cookies(self, mock_db_session, mock_page):
+ """Test loading cookies"""
+ scraper = ConcreteScraper(mock_db_session, "test_source")
+ scraper.page = mock_page
+
+ mock_state = Mock(spec=ScrapingState)
+ mock_state.cookies_json = '[{"name": "test", "value": "value"}]'
+
+ mock_query = Mock()
+ mock_db_session.query.return_value = mock_query
+ mock_query.filter.return_value = mock_query
+ mock_query.first.return_value = mock_state
+
+ scraper._load_cookies()
+
+ mock_page.set.cookies.assert_called()
+
+ def test_save_cookies(self, mock_db_session, mock_page):
+ """Test saving cookies"""
+ scraper = ConcreteScraper(mock_db_session, "test_source")
+ scraper.page = mock_page
+
+ mock_page.cookies.return_value = [{"name": "test", "value": "value"}]
+
+ mock_state = Mock(spec=ScrapingState)
+ mock_query = Mock()
+ mock_db_session.query.return_value = mock_query
+ mock_query.filter.return_value = mock_query
+ mock_query.first.return_value = mock_state
+
+ scraper._save_cookies()
+
+ mock_db_session.commit.assert_called_once()
+
+ def test_random_delay(self, mock_db_session):
+ """Test random delay"""
+ scraper = ConcreteScraper(mock_db_session, "test_source")
+
+ with patch('time.sleep') as mock_sleep:
+ scraper.random_delay(0.1, 0.2)
+ mock_sleep.assert_called_once()
+
+ def test_scroll_page(self, mock_db_session, mock_page):
+ """Test scrolling page"""
+ scraper = ConcreteScraper(mock_db_session, "test_source")
+ scraper.page = mock_page
+
+ with patch('time.sleep'):
+ with patch('random.randint', return_value=500):
+ with patch('random.random', return_value=0.5):
+ scraper.scroll_page(scrolls=2)
+
+ assert mock_page.scroll.down.call_count == 2
+
+ def test_safe_click_success(self, mock_db_session, mock_page):
+ """Test safe click success"""
+ scraper = ConcreteScraper(mock_db_session, "test_source")
+ scraper.page = mock_page
+
+ mock_element = Mock()
+ mock_page.ele.return_value = mock_element
+
+ result = scraper.safe_click("test_selector")
+
+ assert result is True
+ mock_element.click.assert_called_once()
+
+ def test_safe_click_failure(self, mock_db_session, mock_page):
+ """Test safe click failure"""
+ scraper = ConcreteScraper(mock_db_session, "test_source")
+ scraper.page = mock_page
+
+ mock_page.ele.return_value = None
+
+ result = scraper.safe_click("test_selector")
+
+ assert result is False
+
+ def test_safe_fill_success(self, mock_db_session, mock_page):
+ """Test safe fill success"""
+ scraper = ConcreteScraper(mock_db_session, "test_source")
+ scraper.page = mock_page
+
+ mock_element = Mock()
+ mock_page.ele.return_value = mock_element
+
+ result = scraper.safe_fill("test_selector", "test_value")
+
+ assert result is True
+ mock_element.input.assert_called_once_with("test_value")
+
+ def test_safe_get_text_success(self, mock_db_session, mock_page):
+ """Test safe get text success"""
+ scraper = ConcreteScraper(mock_db_session, "test_source")
+ scraper.page = mock_page
+
+ mock_element = Mock()
+ mock_element.text = "Test text"
+ mock_page.ele.return_value = mock_element
+
+ result = scraper.safe_get_text("test_selector")
+
+ assert result == "Test text"
+
+ def test_normalize_phone(self, mock_db_session):
+ """Test phone normalization"""
+ scraper = ConcreteScraper(mock_db_session, "test_source")
+
+ result = scraper.normalize_phone("050-123-4567")
+
+ assert result is not None
+
+ def test_update_scraping_state_success(self, mock_db_session):
+ """Test updating scraping state on success"""
+ scraper = ConcreteScraper(mock_db_session, "test_source")
+
+ mock_state = Mock(spec=ScrapingState)
+ mock_query = Mock()
+ mock_db_session.query.return_value = mock_query
+ mock_query.filter.return_value = mock_query
+ mock_query.first.return_value = mock_state
+
+ scraper.update_scraping_state(success=True)
+
+ assert mock_state.status == 'active'
+ assert mock_state.error_count == 0
+ mock_db_session.commit.assert_called_once()
+
+ def test_update_scraping_state_failure(self, mock_db_session):
+ """Test updating scraping state on failure"""
+ scraper = ConcreteScraper(mock_db_session, "test_source")
+
+ mock_state = Mock(spec=ScrapingState)
+ mock_state.error_count = 0
+ mock_query = Mock()
+ mock_db_session.query.return_value = mock_query
+ mock_query.filter.return_value = mock_query
+ mock_query.first.return_value = mock_state
+
+ scraper.update_scraping_state(success=False, error_msg="Test error")
+
+ assert mock_state.status == 'error'
+ assert mock_state.error_count == 1
+ assert mock_state.error_message == "Test error"
+
+ def test_check_for_captcha_detected(self, mock_db_session, mock_page):
+ """Test CAPTCHA detection"""
+ scraper = ConcreteScraper(mock_db_session, "test_source")
+ scraper.page = mock_page
+
+ mock_page.title = "CAPTCHA Required"
+ mock_page.html = "captcha"
+
+ result = scraper._check_for_captcha()
+
+ assert result is True
+
+ def test_check_for_captcha_not_detected(self, mock_db_session, mock_page):
+ """Test no CAPTCHA detection"""
+ scraper = ConcreteScraper(mock_db_session, "test_source")
+ scraper.page = mock_page
+
+ mock_page.title = "Normal Page"
+ mock_page.html = "normal content"
+
+ result = scraper._check_for_captcha()
+
+ assert result is False
+
+ def test_is_blocked(self, mock_db_session, mock_page):
+ """Test is_blocked method"""
+ scraper = ConcreteScraper(mock_db_session, "test_source")
+ scraper.page = mock_page
+
+ mock_page.title = "Blocked"
+ mock_page.html = "access denied"
+
+ result = scraper._is_blocked()
+
+ assert result is True
+
+ def test_cleanup(self, mock_db_session):
+ """Test cleanup"""
+ scraper = ConcreteScraper(mock_db_session, "test_source")
+
+ with patch.object(scraper, '_save_cookies'):
+ scraper.cleanup()
+ # Should complete without errors
+
+
+class TestScraperWithRetry:
+ """Test ScraperWithRetry wrapper"""
+
+ def test_init(self, mock_db_session):
+ """Test initialization"""
+ scraper = ConcreteScraper(mock_db_session, "test_source")
+ retry_scraper = ScraperWithRetry(scraper, max_retries=3, retry_delay=1)
+
+ assert retry_scraper.scraper == scraper
+ assert retry_scraper.max_retries == 3
+ assert retry_scraper.retry_delay == 1
+
+ def test_scrape_with_retry_success(self, mock_db_session):
+ """Test successful scrape on first attempt"""
+ scraper = ConcreteScraper(mock_db_session, "test_source")
+ retry_scraper = ScraperWithRetry(scraper, max_retries=3, retry_delay=1)
+
+ with patch.object(scraper, 'initialize'):
+ with patch.object(scraper, 'scrape', return_value=[{"title": "Test"}]):
+ with patch.object(scraper, 'cleanup'):
+ with patch.object(scraper, 'update_scraping_state'):
+ result = retry_scraper.scrape_with_retry()
+
+ assert len(result) == 1
+ assert result[0]["title"] == "Test"
+
+ def test_scrape_with_retry_failure_then_success(self, mock_db_session):
+ """Test scrape fails then succeeds"""
+ scraper = ConcreteScraper(mock_db_session, "test_source")
+ retry_scraper = ScraperWithRetry(scraper, max_retries=3, retry_delay=1)
+
+ call_count = [0]
+
+ def scrape_side_effect():
+ call_count[0] += 1
+ if call_count[0] == 1:
+ raise Exception("First attempt failed")
+ return [{"title": "Test"}]
+
+ with patch.object(scraper, 'initialize'):
+ with patch.object(scraper, 'scrape', side_effect=scrape_side_effect):
+ with patch.object(scraper, 'cleanup'):
+ with patch.object(scraper, 'update_scraping_state'):
+ with patch('time.sleep'):
+ result = retry_scraper.scrape_with_retry()
+
+ assert len(result) == 1
+
+ def test_scrape_with_retry_all_failures(self, mock_db_session):
+ """Test all retry attempts fail"""
+ scraper = ConcreteScraper(mock_db_session, "test_source")
+ retry_scraper = ScraperWithRetry(scraper, max_retries=2, retry_delay=1)
+
+ with patch.object(scraper, 'initialize'):
+ with patch.object(scraper, 'scrape', side_effect=Exception("Failed")):
+ with patch.object(scraper, 'cleanup'):
+ with patch.object(scraper, 'update_scraping_state'):
+ with patch('time.sleep'):
+ result = retry_scraper.scrape_with_retry()
+
+ assert result == []
+
+ def test_scrape_with_retry_shutdown(self, mock_db_session):
+ """Test scrape with shutdown signal"""
+ import asyncio
+ scraper = ConcreteScraper(mock_db_session, "test_source")
+ shutdown_event = asyncio.Event()
+ shutdown_event.set()
+
+ retry_scraper = ScraperWithRetry(
+ scraper,
+ max_retries=3,
+ retry_delay=1,
+ shutdown_event=shutdown_event
+ )
+
+ result = retry_scraper.scrape_with_retry()
+
+ assert result == []
diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py
index 9baf1b9..f48b70f 100644
--- a/tests/unit/test_config.py
+++ b/tests/unit/test_config.py
@@ -23,7 +23,8 @@ def test_get_cities_list(self, test_settings):
cities = test_settings.get_cities_list()
assert isinstance(cities, list)
- assert len(cities) == 2
+ assert len(cities) == 3
+ assert 'ืชื ืืืื' in cities
assert 'Tel Aviv' in cities
assert 'Ramat Gan' in cities
diff --git a/tests/unit/test_dashboard.py b/tests/unit/test_dashboard.py
new file mode 100644
index 0000000..6dba228
--- /dev/null
+++ b/tests/unit/test_dashboard.py
@@ -0,0 +1,372 @@
+"""
+Unit tests for dashboard service
+"""
+import pytest
+from datetime import datetime, timedelta
+from unittest.mock import Mock, patch, MagicMock
+from fastapi.testclient import TestClient
+from sqlalchemy.orm import Session
+
+from app.services.dashboard import (
+ app,
+ format_price,
+ days_ago,
+ get_whatsapp_url,
+ get_db
+)
+from app.core.database import Listing, NeighborhoodStats
+
+
+@pytest.fixture
+def test_client():
+ """Create test client"""
+ return TestClient(app)
+
+
+@pytest.fixture
+def mock_db_session():
+ """Create mock database session"""
+ return Mock(spec=Session)
+
+
+class TestHelperFunctions:
+ """Test template helper functions"""
+
+ def test_format_price_with_value(self):
+ """Test price formatting with valid value"""
+ assert format_price(5000) == "โช5,000"
+ assert format_price(10000.5) == "โช10,000"
+ assert format_price(1234567) == "โช1,234,567"
+
+ def test_format_price_none(self):
+ """Test price formatting with None"""
+ assert format_price(None) == "N/A"
+
+ def test_days_ago_just_now(self):
+ """Test days_ago for recent times"""
+ now = datetime.utcnow()
+ assert days_ago(now) == "Just now"
+
+ def test_days_ago_minutes(self):
+ """Test days_ago for minutes"""
+ time = datetime.utcnow() - timedelta(minutes=30)
+ result = days_ago(time)
+ assert "minutes ago" in result
+
+ def test_days_ago_hours(self):
+ """Test days_ago for hours"""
+ time = datetime.utcnow() - timedelta(hours=3)
+ result = days_ago(time)
+ assert "hours ago" in result
+
+ def test_days_ago_yesterday(self):
+ """Test days_ago for yesterday"""
+ time = datetime.utcnow() - timedelta(days=1)
+ assert days_ago(time) == "Yesterday"
+
+ def test_days_ago_days(self):
+ """Test days_ago for days"""
+ time = datetime.utcnow() - timedelta(days=3)
+ result = days_ago(time)
+ assert "days ago" in result
+
+ def test_days_ago_weeks(self):
+ """Test days_ago for weeks"""
+ time = datetime.utcnow() - timedelta(days=14)
+ result = days_ago(time)
+ assert "weeks ago" in result
+
+ def test_days_ago_months(self):
+ """Test days_ago for months"""
+ time = datetime.utcnow() - timedelta(days=60)
+ result = days_ago(time)
+ assert "months ago" in result
+
+ def test_days_ago_none(self):
+ """Test days_ago with None"""
+ assert days_ago(None) == "Unknown"
+
+ def test_get_whatsapp_url_valid(self):
+ """Test WhatsApp URL generation"""
+ url = get_whatsapp_url("0501234567", "Test St, Tel Aviv", "yad2")
+ assert url is not None
+ assert "wa.me" in url
+ assert "+972501234567" in url
+ assert "Test%20St" in url
+
+ def test_get_whatsapp_url_with_country_code(self):
+ """Test WhatsApp URL with existing country code"""
+ url = get_whatsapp_url("+972501234567", "Test St", "yad2")
+ assert "+972501234567" in url
+
+ def test_get_whatsapp_url_none(self):
+ """Test WhatsApp URL with no phone"""
+ assert get_whatsapp_url(None, "Test St", "yad2") is None
+ assert get_whatsapp_url("", "Test St", "yad2") is None
+
+
+class TestDashboardEndpoints:
+ """Test dashboard API endpoints"""
+
+ @patch('app.services.dashboard.get_db')
+ def test_index_page(self, mock_get_db, test_client):
+ """Test main dashboard page"""
+ # Setup mock
+ mock_db = Mock()
+ mock_query = Mock()
+ mock_db.query.return_value = mock_query
+ mock_query.filter.return_value = mock_query
+ mock_query.filter_by.return_value = mock_query
+ mock_query.order_by.return_value = mock_query
+ mock_query.all.return_value = []
+ mock_query.count.return_value = 0
+ mock_query.distinct.return_value = mock_query
+ mock_get_db.return_value.__enter__ = Mock(return_value=mock_db)
+ mock_get_db.return_value.__exit__ = Mock(return_value=False)
+
+ # Make request
+ response = test_client.get("/")
+ assert response.status_code == 200
+
+ @patch('app.services.dashboard.get_db')
+ def test_index_with_filters(self, mock_get_db, test_client):
+ """Test dashboard with filters"""
+ mock_db = Mock()
+ mock_query = Mock()
+ mock_db.query.return_value = mock_query
+ mock_query.filter.return_value = mock_query
+ mock_query.order_by.return_value = mock_query
+ mock_query.all.return_value = []
+ mock_query.count.return_value = 0
+ mock_query.distinct.return_value = mock_query
+ mock_get_db.return_value.__enter__ = Mock(return_value=mock_db)
+ mock_get_db.return_value.__exit__ = Mock(return_value=False)
+
+ response = test_client.get("/?status=interested&min_score=80&max_price=5000")
+ assert response.status_code == 200
+
+ def test_listing_detail_found(self, test_client):
+ """Test listing detail page"""
+ from app.services.dashboard import app, get_db
+
+ def override_get_db():
+ mock_db = Mock()
+ mock_listing = Mock(spec=Listing)
+ mock_listing.id = 1
+ mock_listing.city = "Tel Aviv"
+ mock_listing.neighborhood = "Center"
+ mock_listing.price_history = []
+
+ # Mock the query for NeighborhoodStats
+ def query_side_effect(model):
+ mock_query = Mock()
+ mock_query.filter.return_value = mock_query
+ if model == Listing:
+ mock_query.first.return_value = mock_listing
+ else: # NeighborhoodStats
+ mock_query.first.return_value = None
+ return mock_query
+
+ mock_db.query.side_effect = query_side_effect
+
+ yield mock_db
+
+ app.dependency_overrides[get_db] = override_get_db
+ response = test_client.get("/listing/1")
+ app.dependency_overrides.clear()
+
+ assert response.status_code == 200
+
+ def test_listing_detail_not_found(self, test_client):
+ """Test listing detail page with non-existent listing"""
+ from app.services.dashboard import app, get_db
+
+ def override_get_db():
+ mock_db = Mock()
+ mock_query = Mock()
+ mock_db.query.return_value = mock_query
+ mock_query.filter.return_value = mock_query
+ mock_query.first.return_value = None
+ yield mock_db
+
+ app.dependency_overrides[get_db] = override_get_db
+ response = test_client.get("/listing/999")
+ app.dependency_overrides.clear()
+
+ assert response.status_code == 404
+
+ def test_update_listing_status(self, test_client):
+ """Test updating listing status"""
+ from app.services.dashboard import app, get_db
+
+ def override_get_db():
+ mock_db = Mock()
+ mock_listing = Mock(spec=Listing)
+ mock_listing.id = 1
+
+ mock_query = Mock()
+ mock_db.query.return_value = mock_query
+ mock_query.filter.return_value = mock_query
+ mock_query.first.return_value = mock_listing
+ yield mock_db
+
+ app.dependency_overrides[get_db] = override_get_db
+ response = test_client.post("/api/listing/1/status?status=interested¬e=Test note")
+ app.dependency_overrides.clear()
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["success"] is True
+ assert data["status"] == "interested"
+
+ def test_update_listing_status_invalid(self, test_client):
+ """Test updating listing with invalid status"""
+ response = test_client.post("/api/listing/1/status?status=invalid_status")
+ assert response.status_code == 400
+
+ @patch('app.services.dashboard.get_db')
+ def test_get_stats(self, mock_get_db, test_client):
+ """Test stats endpoint"""
+ mock_db = Mock()
+ mock_query = Mock()
+ mock_db.query.return_value = mock_query
+ mock_query.filter.return_value = mock_query
+ mock_query.count.return_value = 10
+ mock_query.scalar.return_value = 5000.0
+ mock_query.group_by.return_value = mock_query
+ mock_query.all.return_value = [("unseen", 5), ("interested", 3)]
+ mock_get_db.return_value.__enter__ = Mock(return_value=mock_db)
+ mock_get_db.return_value.__exit__ = Mock(return_value=False)
+
+ response = test_client.get("/api/stats")
+ assert response.status_code == 200
+ data = response.json()
+ assert "total_listings" in data
+ assert "new_today" in data
+
+ @patch('app.services.dashboard.get_db')
+ def test_get_neighborhood_stats(self, mock_get_db, test_client):
+ """Test neighborhood stats endpoint"""
+ mock_db = Mock()
+ mock_stats = Mock(spec=NeighborhoodStats)
+ mock_stats.city = "Tel Aviv"
+ mock_stats.neighborhood = "Center"
+ mock_stats.avg_price = 5000
+ mock_stats.avg_price_per_sqm = 100
+ mock_stats.median_price = 4800
+ mock_stats.median_price_per_sqm = 95
+ mock_stats.sample_size = 50
+
+ mock_query = Mock()
+ mock_db.query.return_value = mock_query
+ mock_query.filter.return_value = mock_query
+ mock_query.all.return_value = [mock_stats]
+ mock_get_db.return_value.__enter__ = Mock(return_value=mock_db)
+ mock_get_db.return_value.__exit__ = Mock(return_value=False)
+
+ response = test_client.get("/api/neighborhood-stats?city=Tel Aviv")
+ assert response.status_code == 200
+ data = response.json()
+ assert "neighborhoods" in data
+
+ def test_get_price_history(self, test_client):
+ """Test price history endpoint"""
+ from app.services.dashboard import app, get_db
+
+ def override_get_db():
+ mock_db = Mock()
+ mock_listing = Mock(spec=Listing)
+ mock_listing.id = 1
+
+ mock_history = Mock()
+ mock_history.timestamp = datetime.utcnow()
+ mock_history.price = 5000
+ mock_history.price_per_sqm = 100
+ mock_listing.price_history = [mock_history]
+
+ mock_query = Mock()
+ mock_db.query.return_value = mock_query
+ mock_query.filter.return_value = mock_query
+ mock_query.first.return_value = mock_listing
+ yield mock_db
+
+ app.dependency_overrides[get_db] = override_get_db
+ response = test_client.get("/api/price-history/1")
+ app.dependency_overrides.clear()
+
+ assert response.status_code == 200
+ data = response.json()
+ assert "listing_id" in data
+ assert "history" in data
+
+ @patch('app.services.dashboard.get_db')
+ def test_health_check(self, mock_get_db, test_client):
+ """Test health check endpoint"""
+ mock_db = Mock()
+ mock_query = Mock()
+ mock_db.query.return_value = mock_query
+ mock_query.all.return_value = []
+ mock_get_db.return_value.__enter__ = Mock(return_value=mock_db)
+ mock_get_db.return_value.__exit__ = Mock(return_value=False)
+
+ response = test_client.get("/health")
+ assert response.status_code == 200
+ data = response.json()
+ assert data["status"] == "healthy"
+ assert "timestamp" in data
+
+ @patch('app.scrapers.base_scraper.captcha_state')
+ def test_api_health_check(self, mock_captcha_state, test_client):
+ """Test API health check endpoint"""
+ from app.services.dashboard import app, get_db
+
+ def override_get_db():
+ mock_db = Mock()
+ mock_query = Mock()
+ mock_db.query.return_value = mock_query
+ mock_query.all.return_value = []
+ yield mock_db
+
+ mock_captcha_state.is_waiting.return_value = False
+ mock_captcha_state.get_status.return_value = {"status": "NORMAL"}
+
+ app.dependency_overrides[get_db] = override_get_db
+ response = test_client.get("/api/health")
+ app.dependency_overrides.clear()
+
+ assert response.status_code == 200
+ data = response.json()
+ assert "is_blocked" in data
+ assert "captcha_state" in data
+
+ @patch('app.scrapers.base_scraper.captcha_state')
+ def test_get_scraper_status(self, mock_captcha_state, test_client):
+ """Test scraper status endpoint"""
+ mock_captcha_state.get_status.return_value = {"status": "NORMAL"}
+ mock_captcha_state.is_waiting.return_value = False
+
+ response = test_client.get("/api/status")
+ assert response.status_code == 200
+ data = response.json()
+ assert "captcha" in data
+ assert "is_paused" in data
+
+ @patch('app.services.dashboard.get_db')
+ def test_database_stats(self, mock_get_db, test_client):
+ """Test database stats endpoint"""
+ mock_db = Mock()
+ mock_query = Mock()
+ mock_db.query.return_value = mock_query
+ mock_query.filter.return_value = mock_query
+ mock_query.count.return_value = 10
+ mock_query.group_by.return_value = mock_query
+ mock_query.all.return_value = []
+ mock_get_db.return_value.__enter__ = Mock(return_value=mock_db)
+ mock_get_db.return_value.__exit__ = Mock(return_value=False)
+
+ response = test_client.get("/api/db-stats")
+ assert response.status_code == 200
+ data = response.json()
+ assert "database" in data
+ assert "scrapers" in data
+ assert "timestamp" in data
diff --git a/tests/unit/test_facebook_scraper.py b/tests/unit/test_facebook_scraper.py
new file mode 100644
index 0000000..20f8b90
--- /dev/null
+++ b/tests/unit/test_facebook_scraper.py
@@ -0,0 +1,343 @@
+"""
+Unit tests for Facebook scraper
+"""
+import pytest
+from unittest.mock import Mock, patch, MagicMock
+import os
+
+from app.scrapers.facebook_scraper import FacebookScraper
+
+
+@pytest.fixture
+def mock_db_session():
+ """Create mock database session"""
+ return Mock()
+
+
+@pytest.fixture
+def mock_page():
+ """Create mock ChromiumPage"""
+ page = Mock()
+ page.url = "https://www.facebook.com/marketplace"
+ page.html = "Test"
+ return page
+
+
+@pytest.fixture
+def scraper(mock_db_session):
+ """Create FacebookScraper instance"""
+ return FacebookScraper(mock_db_session, cookies_file="test_cookies.json")
+
+
+class TestFacebookScraper:
+ """Test FacebookScraper class"""
+
+ def test_init(self, mock_db_session):
+ """Test scraper initialization"""
+ scraper = FacebookScraper(mock_db_session, cookies_file="test.json")
+
+ assert scraper.source_name == "facebook"
+ assert scraper.base_url == "https://www.facebook.com"
+ assert scraper.cookies_file == "test.json"
+
+ def test_load_cookies_file_exists(self, scraper, mock_page):
+ """Test loading cookies from file"""
+ scraper.page = mock_page
+
+ cookies_data = '[{"name": "test", "value": "value"}]'
+
+ with patch('os.path.exists', return_value=True):
+ with patch('builtins.open', create=True) as mock_open:
+ mock_open.return_value.__enter__.return_value.read.return_value = cookies_data
+
+ scraper._load_cookies()
+
+ mock_page.set.cookies.assert_called()
+
+ def test_load_cookies_file_not_exists(self, scraper):
+ """Test loading cookies when file doesn't exist"""
+ scraper.page = Mock()
+
+ with patch('os.path.exists', return_value=False):
+ # Should not raise error
+ scraper._load_cookies()
+
+ def test_load_cookies_no_file(self, mock_db_session):
+ """Test loading cookies with no file specified"""
+ scraper = FacebookScraper(mock_db_session, cookies_file=None)
+ scraper.page = Mock()
+
+ # Should not raise error
+ scraper._load_cookies()
+
+ def test_extract_price_from_text(self, scraper):
+ """Test extracting price from text"""
+ text = "Great apartment for โช5,000 per month"
+ price = scraper._extract_price_from_text(text)
+
+ assert price == 5000.0
+
+ def test_extract_price_from_text_no_price(self, scraper):
+ """Test extracting price when none found"""
+ text = "Great apartment"
+ price = scraper._extract_price_from_text(text)
+
+ assert price is None
+
+ def test_extract_rooms(self, scraper):
+ """Test extracting number of rooms"""
+ text = "3 ืืืจืื apartment"
+ rooms = scraper._extract_rooms(text)
+
+ assert rooms == 3.0
+
+ def test_extract_rooms_decimal(self, scraper):
+ """Test extracting decimal rooms"""
+ text = "3.5 rooms available"
+ rooms = scraper._extract_rooms(text)
+
+ assert rooms == 3.5
+
+ def test_extract_rooms_no_rooms(self, scraper):
+ """Test extracting rooms when none found"""
+ text = "Great apartment"
+ rooms = scraper._extract_rooms(text)
+
+ assert rooms is None
+
+ def test_extract_size(self, scraper):
+ """Test extracting size"""
+ text = "Apartment 80 ืืดืจ"
+ size = scraper._extract_size(text)
+
+ assert size == 80.0
+
+ def test_extract_size_no_size(self, scraper):
+ """Test extracting size when none found"""
+ text = "Great apartment"
+ size = scraper._extract_size(text)
+
+ assert size is None
+
+ def test_extract_location_from_text(self, scraper):
+ """Test extracting location"""
+ text = "Apartment in ืชื ืืืื, ืจืืช ืืืื"
+ city, neighborhood, street = scraper._extract_location_from_text(text)
+
+ assert city == "ืชื ืืืื"
+ assert neighborhood == "ืจืืช ืืืื"
+
+ def test_extract_location_no_location(self, scraper):
+ """Test extracting location when none found"""
+ text = "Great apartment"
+ city, neighborhood, street = scraper._extract_location_from_text(text)
+
+ assert city is None
+ assert neighborhood is None
+ assert street is None
+
+ def test_extract_listing_data_success(self, scraper):
+ """Test extracting listing data from card"""
+ mock_card = Mock()
+ mock_card.text = "3 ืืืจืื, 80 ืืดืจ, โช5,000, ืชื ืืืื"
+
+ mock_link = Mock()
+ mock_link.link = "https://www.facebook.com/marketplace/item/123456"
+ mock_card.ele.return_value = mock_link
+
+ mock_title = Mock()
+ mock_title.text = "Test Apartment"
+
+ def ele_side_effect(selector, timeout=2):
+ if 'title' in selector or selector == 'tag:h2':
+ return mock_title
+ if selector == 'tag:a':
+ return mock_link
+ return None
+
+ mock_card.ele = Mock(side_effect=ele_side_effect)
+ mock_card.eles.return_value = []
+
+ data = scraper._extract_listing_data(mock_card)
+
+ assert data is not None
+ assert data['external_id'] == '123456'
+ assert data['title'] == "Test Apartment"
+ assert data['price'] == 5000.0
+ assert data['rooms'] == 3.0
+ assert data['size_sqm'] == 80.0
+
+ def test_extract_listing_data_no_link(self, scraper):
+ """Test extracting data with no link"""
+ mock_card = Mock()
+ mock_card.ele.return_value = None
+
+ data = scraper._extract_listing_data(mock_card)
+
+ assert data is None
+
+ def test_parse_listing_success(self, scraper):
+ """Test parsing listing data"""
+ raw_data = {
+ 'external_id': '123456',
+ 'url': 'https://example.com',
+ 'title': 'Test Apartment',
+ 'price': 5000,
+ 'rooms': 3,
+ 'size_sqm': 80,
+ 'floor': 2,
+ 'city': 'Tel Aviv',
+ 'neighborhood': 'Center',
+ 'street': 'Main St',
+ 'details_text': 'Great apartment with ืืขืืืช and ืื ืื',
+ 'contact_name': 'John',
+ 'contact_phone': '0501234567',
+ 'images': ['img1.jpg']
+ }
+
+ parsed = scraper.parse_listing(raw_data)
+
+ assert parsed is not None
+ assert parsed['source'] == 'facebook'
+ assert parsed['title'] == 'Test Apartment'
+ assert parsed['price'] == 5000
+ assert parsed['has_elevator'] is True
+ assert parsed['has_parking'] is True
+ assert parsed['price_per_sqm'] == 5000 / 80
+
+ def test_parse_listing_with_features(self, scraper):
+ """Test parsing listing with features"""
+ raw_data = {
+ 'external_id': '123',
+ 'url': 'https://example.com',
+ 'title': 'Test',
+ 'price': 5000,
+ 'size_sqm': 50,
+ 'details_text': 'ืืขืืืช, ืื ืื, ืืจืคืกืช, ืื"ื',
+ 'city': 'Tel Aviv',
+ 'neighborhood': 'Center',
+ 'street': 'Main St',
+ 'contact_name': '',
+ 'contact_phone': '',
+ 'images': []
+ }
+
+ parsed = scraper.parse_listing(raw_data)
+
+ assert parsed['has_elevator'] is True
+ assert parsed['has_parking'] is True
+ assert parsed['has_balcony'] is True
+ assert parsed['has_mamad'] is True
+
+ def test_parse_listing_no_features(self, scraper):
+ """Test parsing listing without features"""
+ raw_data = {
+ 'external_id': '123',
+ 'url': 'https://example.com',
+ 'title': 'Test',
+ 'price': 5000,
+ 'size_sqm': 50,
+ 'details_text': 'Simple apartment',
+ 'city': 'Tel Aviv',
+ 'neighborhood': 'Center',
+ 'street': 'Main St',
+ 'contact_name': '',
+ 'contact_phone': '',
+ 'images': []
+ }
+
+ parsed = scraper.parse_listing(raw_data)
+
+ assert parsed['has_elevator'] is False
+ assert parsed['has_parking'] is False
+ assert parsed['has_balcony'] is False
+ assert parsed['has_mamad'] is False
+
+ def test_parse_listing_error(self, scraper):
+ """Test parsing with error"""
+ raw_data = None
+
+ parsed = scraper.parse_listing(raw_data)
+
+ assert parsed is None
+
+ @patch('app.scrapers.facebook_scraper.BaseScraper.initialize')
+ def test_scrape_no_page(self, mock_init, scraper):
+ """Test scraping with no page"""
+ scraper.page = None
+
+ result = scraper.scrape()
+
+ assert result == []
+
+ @patch('app.scrapers.facebook_scraper.BaseScraper.initialize')
+ def test_scrape_login_required(self, mock_init, scraper, mock_page):
+ """Test scraping when login is required"""
+ scraper.page = mock_page
+ mock_page.url = "https://www.facebook.com/login"
+
+ with patch.object(scraper, '_handle_anti_bot_protection'):
+ with patch.object(scraper, 'human_like_mouse_movement'):
+ with patch.object(scraper, 'scroll_page'):
+ with patch.object(scraper, 'random_delay'):
+ result = scraper.scrape()
+
+ assert result == []
+
+ @patch('app.scrapers.facebook_scraper.BaseScraper.initialize')
+ def test_scrape_success(self, mock_init, scraper, mock_page):
+ """Test successful scraping"""
+ scraper.page = mock_page
+
+ # Mock listing cards
+ mock_card = Mock()
+ mock_card.text = "3 ืืืจืื, 80 ืืดืจ, โช5,000"
+
+ mock_link = Mock()
+ mock_link.link = "https://www.facebook.com/marketplace/item/123456"
+
+ def ele_side_effect(selector, timeout=2):
+ if selector == 'tag:a':
+ return mock_link
+ if 'title' in selector or selector == 'tag:h2':
+ mock_title = Mock()
+ mock_title.text = "Test Apartment"
+ return mock_title
+ return None
+
+ mock_card.ele = Mock(side_effect=ele_side_effect)
+ mock_card.eles.return_value = []
+
+ mock_page.eles.return_value = [mock_card]
+
+ with patch.object(scraper, '_handle_anti_bot_protection'):
+ with patch.object(scraper, 'human_like_mouse_movement'):
+ with patch.object(scraper, 'scroll_page'):
+ with patch.object(scraper, 'random_delay'):
+ result = scraper.scrape()
+
+ assert len(result) > 0
+
+ @patch('app.scrapers.facebook_scraper.BaseScraper.initialize')
+ def test_scrape_no_listings(self, mock_init, scraper, mock_page):
+ """Test scraping with no listings found"""
+ scraper.page = mock_page
+ mock_page.eles.return_value = []
+
+ with patch.object(scraper, '_handle_anti_bot_protection'):
+ with patch.object(scraper, 'human_like_mouse_movement'):
+ with patch.object(scraper, 'scroll_page'):
+ with patch.object(scraper, 'random_delay'):
+ with patch.object(scraper, 'debug_save_page'):
+ result = scraper.scrape()
+
+ assert result == []
+
+ @patch('app.scrapers.facebook_scraper.BaseScraper.initialize')
+ def test_scrape_error(self, mock_init, scraper, mock_page):
+ """Test scraping with error"""
+ scraper.page = mock_page
+ mock_page.get.side_effect = Exception("Test error")
+
+ with pytest.raises(Exception):
+ scraper.scrape()
diff --git a/tests/unit/test_graceful_shutdown.py b/tests/unit/test_graceful_shutdown.py
new file mode 100644
index 0000000..5d70f43
--- /dev/null
+++ b/tests/unit/test_graceful_shutdown.py
@@ -0,0 +1,247 @@
+"""
+Unit tests for graceful shutdown functionality in Scheduler and ScraperWithRetry.
+"""
+import pytest
+import asyncio
+import threading
+from unittest.mock import Mock, MagicMock, patch
+from datetime import datetime
+
+from app.services.scheduler import ScrapingScheduler
+from app.scrapers.base_scraper import ScraperWithRetry, BaseScraper
+
+
+class TestScraperWithRetryShutdown:
+ """Test graceful shutdown in ScraperWithRetry"""
+
+ def test_shutdown_event_breaks_retry_loop(self, db_session, mock_chromium_page):
+ """Test that shutdown_event.set() breaks the retry loop"""
+ # Create shutdown event
+ shutdown_event = threading.Event()
+
+ # Create a mock scraper that always fails
+ mock_scraper = Mock(spec=BaseScraper)
+ mock_scraper.source_name = 'test_scraper'
+ mock_scraper.initialize = Mock(side_effect=Exception("Test error"))
+ mock_scraper.cleanup = Mock()
+ mock_scraper.update_scraping_state = Mock()
+
+ # Create scraper with retry
+ scraper_with_retry = ScraperWithRetry(
+ scraper=mock_scraper,
+ max_retries=10, # High number to ensure we test shutdown, not exhaustion
+ retry_delay=1,
+ shutdown_event=shutdown_event
+ )
+
+ # Set shutdown event after a short delay in a separate thread
+ def set_shutdown():
+ import time
+ time.sleep(0.5) # Wait a bit before shutting down
+ shutdown_event.set()
+
+ shutdown_thread = threading.Thread(target=set_shutdown)
+ shutdown_thread.start()
+
+ # Run scraper - should exit quickly due to shutdown event
+ start_time = datetime.utcnow()
+ result = scraper_with_retry.scrape_with_retry()
+ elapsed = (datetime.utcnow() - start_time).total_seconds()
+
+ # Verify it returned empty list
+ assert result == []
+
+ # Verify it didn't run for full retry duration (10 retries * 1 second = 10 seconds)
+ # Should exit much faster due to shutdown event
+ assert elapsed < 5, f"Shutdown took too long: {elapsed} seconds"
+
+ # Verify initialize was called at least once
+ assert mock_scraper.initialize.call_count >= 1
+
+ # Cleanup
+ shutdown_thread.join()
+
+ def test_shutdown_during_retry_delay(self, db_session, mock_chromium_page):
+ """Test that shutdown event is checked during retry delay"""
+ shutdown_event = threading.Event()
+
+ # Create a mock scraper that fails on first attempt
+ mock_scraper = Mock(spec=BaseScraper)
+ mock_scraper.source_name = 'test_scraper'
+ mock_scraper.initialize = Mock(side_effect=Exception("Test error"))
+ mock_scraper.cleanup = Mock()
+ mock_scraper.update_scraping_state = Mock()
+
+ scraper_with_retry = ScraperWithRetry(
+ scraper=mock_scraper,
+ max_retries=5,
+ retry_delay=10, # Long delay to test shutdown during wait
+ shutdown_event=shutdown_event
+ )
+
+ # Set shutdown event after a short delay
+ def set_shutdown():
+ import time
+ time.sleep(0.5)
+ shutdown_event.set()
+
+ shutdown_thread = threading.Thread(target=set_shutdown)
+ shutdown_thread.start()
+
+ # Run scraper
+ start_time = datetime.utcnow()
+ result = scraper_with_retry.scrape_with_retry()
+ elapsed = (datetime.utcnow() - start_time).total_seconds()
+
+ # Should exit quickly, not wait for full retry_delay
+ assert elapsed < 5, f"Shutdown during retry delay took too long: {elapsed} seconds"
+ assert result == []
+
+ shutdown_thread.join()
+
+ def test_no_shutdown_event_completes_normally(self, db_session, mock_chromium_page):
+ """Test that scraper works normally without shutdown event"""
+ # Create a mock scraper that succeeds
+ mock_scraper = Mock(spec=BaseScraper)
+ mock_scraper.source_name = 'test_scraper'
+ mock_scraper.initialize = Mock()
+ mock_scraper.scrape = Mock(return_value=[{'test': 'data'}])
+ mock_scraper.cleanup = Mock()
+ mock_scraper.update_scraping_state = Mock()
+
+ # No shutdown event
+ scraper_with_retry = ScraperWithRetry(
+ scraper=mock_scraper,
+ max_retries=3,
+ retry_delay=1,
+ shutdown_event=None
+ )
+
+ # Run scraper
+ result = scraper_with_retry.scrape_with_retry()
+
+ # Should complete successfully
+ assert result == [{'test': 'data'}]
+ assert mock_scraper.initialize.call_count == 1
+ assert mock_scraper.scrape.call_count == 1
+ assert mock_scraper.cleanup.call_count == 1
+
+
+class TestSchedulerShutdown:
+ """Test graceful shutdown in ScrapingScheduler"""
+
+ @pytest.mark.asyncio
+ async def test_scheduler_respects_shutdown_event(self, db_session):
+ """Test that scheduler checks shutdown event before scraping"""
+ # Create shutdown event and set it immediately
+ shutdown_event = asyncio.Event()
+ shutdown_event.set()
+
+ # Create scheduler with shutdown event
+ with patch('app.services.scheduler.init_db') as mock_init_db:
+ mock_init_db.return_value = (Mock(), Mock(return_value=db_session))
+
+ scheduler = ScrapingScheduler(shutdown_event=shutdown_event)
+
+ # Try to run scrape methods - they should exit immediately
+ await scheduler.scrape_yad2()
+ await scheduler.scrape_madlan()
+ await scheduler.scrape_facebook()
+
+ # No assertions needed - if we get here without hanging, test passes
+
+ @pytest.mark.asyncio
+ async def test_scheduler_shutdown_prevents_scraping(self):
+ """Test that setting shutdown event prevents new scraping jobs"""
+ shutdown_event = asyncio.Event()
+
+ with patch('app.services.scheduler.init_db') as mock_init_db:
+ mock_session = Mock()
+ mock_init_db.return_value = (Mock(), Mock(return_value=mock_session))
+
+ scheduler = ScrapingScheduler(shutdown_event=shutdown_event)
+
+ # Set shutdown event
+ shutdown_event.set()
+
+ # Try to scrape - should exit immediately without creating scrapers
+ with patch('app.services.scheduler.Yad2Scraper') as mock_yad2:
+ await scheduler.scrape_yad2()
+ # Scraper should not be instantiated
+ mock_yad2.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_initial_scrape_with_shutdown(self):
+ """Test that initial scrape sequence respects shutdown event"""
+ shutdown_event = asyncio.Event()
+
+ with patch('app.services.scheduler.init_db') as mock_init_db:
+ mock_session = Mock()
+ mock_init_db.return_value = (Mock(), Mock(return_value=mock_session))
+
+ scheduler = ScrapingScheduler(shutdown_event=shutdown_event)
+
+ # Set shutdown event before initial scrape
+ shutdown_event.set()
+
+ # Run initial scrape - should complete quickly
+ start_time = datetime.utcnow()
+ await scheduler.run_initial_scrape()
+ elapsed = (datetime.utcnow() - start_time).total_seconds()
+
+ # Should exit quickly (within 10 seconds, not wait for full sequence)
+ assert elapsed < 10, f"Initial scrape with shutdown took too long: {elapsed} seconds"
+
+
+class TestAsyncSleepUsage:
+ """Test that asyncio.sleep is used instead of time.sleep in async contexts"""
+
+ @pytest.mark.asyncio
+ async def test_scheduler_uses_asyncio_sleep(self):
+ """Test that scheduler uses asyncio.sleep for delays"""
+ shutdown_event = asyncio.Event()
+
+ with patch('app.services.scheduler.init_db') as mock_init_db:
+ mock_session = Mock()
+ mock_init_db.return_value = (Mock(), Mock(return_value=mock_session))
+
+ scheduler = ScrapingScheduler(shutdown_event=shutdown_event)
+
+ # Patch asyncio.sleep to verify it's called
+ with patch('asyncio.sleep', new_callable=MagicMock) as mock_sleep:
+ mock_sleep.return_value = asyncio.coroutine(lambda: None)()
+
+ # Run initial scrape (which has sleep calls)
+ shutdown_event.set() # Set to exit quickly
+ await scheduler.run_initial_scrape()
+
+ # Verify asyncio.sleep was called (for the 5 second initial delay)
+ assert mock_sleep.call_count >= 1, "asyncio.sleep should be called in async methods"
+
+
+class TestShutdownEventPropagation:
+ """Test that shutdown event is properly propagated through the system"""
+
+ def test_shutdown_event_passed_to_scraper_with_retry(self, db_session):
+ """Test that shutdown event is passed from scheduler to ScraperWithRetry"""
+ shutdown_event = threading.Event()
+
+ mock_scraper = Mock(spec=BaseScraper)
+ mock_scraper.source_name = 'test'
+
+ # Create ScraperWithRetry with shutdown event
+ scraper_with_retry = ScraperWithRetry(
+ scraper=mock_scraper,
+ max_retries=3,
+ retry_delay=1,
+ shutdown_event=shutdown_event
+ )
+
+ # Verify shutdown event is stored
+ assert scraper_with_retry.shutdown_event is shutdown_event
+
+ # Set shutdown event
+ shutdown_event.set()
+
+ # Verify it's set
+ assert scraper_with_retry.shutdown_event.is_set()
diff --git a/tests/unit/test_listing_processor.py b/tests/unit/test_listing_processor.py
new file mode 100644
index 0000000..34f8fb1
--- /dev/null
+++ b/tests/unit/test_listing_processor.py
@@ -0,0 +1,378 @@
+"""
+Unit tests for ListingProcessor with parameterized deal score calculations.
+"""
+import pytest
+from datetime import datetime, timedelta
+from unittest.mock import Mock, MagicMock
+
+from app.core.listing_processor import ListingProcessor
+from app.core.deal_score import DealScoreCalculator
+from app.core.database import Listing, NeighborhoodStats, PriceHistory
+
+
+class TestDealScoreCalculation:
+ """Test deal score calculation with various apartment scenarios"""
+
+ @pytest.mark.parametrize("scenario,listing_data,neighborhood_avg,expected_score_range", [
+ # Scenario 1: Under-priced apartment with all features
+ (
+ "under_priced_with_features",
+ {
+ 'price_per_sqm': 21000, # 30% below average
+ 'has_parking': True,
+ 'has_balcony': True,
+ 'has_elevator': True,
+ 'has_mamad': True,
+ 'floor': 4,
+ 'total_floors': 5,
+ 'first_seen': datetime.utcnow(), # Fresh listing
+ 'price_history': []
+ },
+ 30000, # Neighborhood average
+ (85, 100) # Expected high score
+ ),
+ # Scenario 2: Over-priced apartment with missing features
+ (
+ "over_priced_no_features",
+ {
+ 'price_per_sqm': 42000, # 40% above average
+ 'has_parking': False,
+ 'has_balcony': False,
+ 'has_elevator': False,
+ 'has_mamad': False,
+ 'floor': 0, # Ground floor
+ 'total_floors': 5,
+ 'first_seen': datetime.utcnow() - timedelta(days=30), # Old listing
+ 'price_history': []
+ },
+ 30000,
+ (0, 25) # Expected low score
+ ),
+ # Scenario 3: Average price with some features
+ (
+ "average_price_some_features",
+ {
+ 'price_per_sqm': 30000, # At average
+ 'has_parking': True,
+ 'has_balcony': True,
+ 'has_elevator': False,
+ 'has_mamad': False,
+ 'floor': 2,
+ 'total_floors': 4,
+ 'first_seen': datetime.utcnow() - timedelta(days=5),
+ 'price_history': []
+ },
+ 30000,
+ (40, 65) # Expected medium score
+ ),
+ # Scenario 4: Good price (price drop tested separately)
+ (
+ "good_price_recent",
+ {
+ 'price_per_sqm': 27000, # 10% below average
+ 'has_parking': True,
+ 'has_balcony': True,
+ 'has_elevator': True,
+ 'has_mamad': True,
+ 'floor': 3,
+ 'total_floors': 5,
+ 'first_seen': datetime.utcnow() - timedelta(days=2),
+ 'price_history': []
+ },
+ 30000,
+ (70, 90) # Expected high score
+ ),
+ # Scenario 5: Missing neighborhood data (neutral scoring)
+ (
+ "no_neighborhood_data",
+ {
+ 'price_per_sqm': 28000,
+ 'has_parking': True,
+ 'has_balcony': False,
+ 'has_elevator': True,
+ 'has_mamad': False,
+ 'floor': 2,
+ 'total_floors': 4,
+ 'first_seen': datetime.utcnow() - timedelta(days=2),
+ 'price_history': []
+ },
+ None, # No neighborhood data
+ (45, 70) # Expected medium score with neutral price component
+ ),
+ ])
+ def test_calculate_score_scenarios(
+ self,
+ db_session,
+ test_settings,
+ scenario,
+ listing_data,
+ neighborhood_avg,
+ expected_score_range
+ ):
+ """Test deal score calculation with various apartment scenarios"""
+ # Create neighborhood stats if provided
+ if neighborhood_avg:
+ stats = NeighborhoodStats(
+ city='ืชื ืืืื',
+ neighborhood='ืคืืืจื ืืื',
+ avg_price=neighborhood_avg * 85, # Assuming 85 sqm
+ avg_price_per_sqm=neighborhood_avg,
+ median_price=neighborhood_avg * 85,
+ median_price_per_sqm=neighborhood_avg,
+ sample_size=20,
+ last_updated=datetime.utcnow()
+ )
+ db_session.add(stats)
+ db_session.commit()
+
+ # Create listing
+ listing = Listing(
+ property_hash='test_hash_' + scenario,
+ source='yad2',
+ external_id=scenario,
+ url=f'https://example.com/{scenario}',
+ title=f'Test Listing - {scenario}',
+ description='Test description',
+ address='ืจืืื ืืจืฆื, ืคืืืจื ืืื, ืชื ืืืื',
+ city='ืชื ืืืื',
+ neighborhood='ืคืืืจื ืืื',
+ street='ืจืืื ืืจืฆื',
+ rooms=3.5,
+ size_sqm=85.0,
+ floor=listing_data['floor'],
+ total_floors=listing_data['total_floors'],
+ price=listing_data['price_per_sqm'] * 85,
+ price_per_sqm=listing_data['price_per_sqm'],
+ has_elevator=listing_data['has_elevator'],
+ has_parking=listing_data['has_parking'],
+ has_balcony=listing_data['has_balcony'],
+ has_mamad=listing_data.get('has_mamad', False),
+ contact_name='Test Contact',
+ contact_phone='0501234567',
+ first_seen=listing_data['first_seen'],
+ last_seen=datetime.utcnow(),
+ last_checked=datetime.utcnow(),
+ status='unseen',
+ deal_score=0.0
+ )
+
+ # Add price history if provided
+ if listing_data.get('price_history'):
+ listing.price_history = listing_data['price_history']
+
+ db_session.add(listing)
+ db_session.commit()
+ db_session.refresh(listing)
+
+ # Calculate score
+ calculator = DealScoreCalculator(db_session)
+ score = calculator.calculate_score(listing)
+
+ # Verify score is in expected range
+ min_score, max_score = expected_score_range
+ assert min_score <= score <= max_score, (
+ f"Scenario '{scenario}': Score {score:.1f} not in expected range "
+ f"[{min_score}, {max_score}]"
+ )
+
+ # Verify score is valid
+ assert 0 <= score <= 100, f"Score {score} is out of valid range [0, 100]"
+
+
+class TestDeduplication:
+ """Test deduplication logic"""
+
+ def test_deduplicate_by_property_hash(self, db_session, test_settings):
+ """Test that duplicate listings are detected by property hash"""
+ # Use test_settings to ensure filters pass
+ processor = ListingProcessor(db_session)
+
+ # Create first listing that passes filters
+ # test_settings has cities="ืชื ืืืื,Tel Aviv,Ramat Gan"
+ listing_data_1 = {
+ 'source': 'yad2',
+ 'external_id': '12345',
+ 'url': 'https://www.yad2.co.il/item/12345',
+ 'title': 'ืืืจืช 3 ืืืจืื',
+ 'description': 'ืืืจื ืืคื',
+ 'address': 'ืจืืื ืืจืฆื 10, ืชื ืืืื',
+ 'city': 'Tel Aviv', # Match test_settings cities
+ 'neighborhood': 'ืคืืืจื ืืื',
+ 'street': 'ืจืืื ืืจืฆื',
+ 'rooms': 3.5, # Above min_rooms (3.0)
+ 'size_sqm': 80.0, # Above min_size_sqm (70)
+ 'floor': 2,
+ 'total_floors': 4,
+ 'price': 2500000, # Below max_price (3000000)
+ 'price_per_sqm': 31250,
+ 'has_elevator': True,
+ 'has_parking': True,
+ 'has_balcony': True,
+ 'contact_name': 'Test',
+ 'contact_phone': '0501234567'
+ }
+
+ stats = processor.process_listings([listing_data_1], 'yad2')
+ assert stats['new'] == 1, f"Expected 1 new listing, got stats: {stats}"
+ assert stats['duplicates'] == 0
+
+ # Try to add same listing again (different external_id but same property)
+ listing_data_2 = listing_data_1.copy()
+ listing_data_2['external_id'] = '67890'
+ listing_data_2['url'] = 'https://www.yad2.co.il/item/67890'
+
+ stats = processor.process_listings([listing_data_2], 'yad2')
+ assert stats['new'] == 0
+ assert stats['duplicates'] == 1
+
+ def test_deduplicate_by_phone(self, db_session, test_settings):
+ """Test that duplicate listings are detected by phone number"""
+ processor = ListingProcessor(db_session)
+
+ # Create first listing that passes filters
+ listing_data_1 = {
+ 'source': 'yad2',
+ 'external_id': '12345',
+ 'url': 'https://www.yad2.co.il/item/12345',
+ 'title': 'ืืืจืช 3 ืืืจืื',
+ 'description': 'ืืืจื ืืคื',
+ 'address': 'ืจืืื ืืจืฆื 10, ืชื ืืืื',
+ 'city': 'Tel Aviv', # Match test_settings cities
+ 'neighborhood': 'ืคืืืจื ืืื',
+ 'street': 'ืจืืื ืืจืฆื',
+ 'rooms': 3.5, # Above min_rooms
+ 'size_sqm': 80.0, # Above min_size_sqm
+ 'floor': 2,
+ 'total_floors': 4,
+ 'price': 2500000, # Below max_price
+ 'price_per_sqm': 31250,
+ 'has_elevator': True,
+ 'has_parking': True,
+ 'has_balcony': True,
+ 'contact_name': 'Test',
+ 'contact_phone': '050-123-4567' # With dashes
+ }
+
+ stats = processor.process_listings([listing_data_1], 'yad2')
+ assert stats['new'] == 1, f"Expected 1 new listing, got stats: {stats}"
+
+ # Try to add listing with same phone (different format) but different address
+ listing_data_2 = listing_data_1.copy()
+ listing_data_2['external_id'] = '67890'
+ listing_data_2['url'] = 'https://www.yad2.co.il/item/67890'
+ listing_data_2['address'] = 'ืจืืื ืืืจ 20, ืชื ืืืื'
+ listing_data_2['contact_phone'] = '0501234567' # Without dashes
+
+ stats = processor.process_listings([listing_data_2], 'yad2')
+ # Should be detected as duplicate due to same phone
+ assert stats['duplicates'] >= 1
+
+ def test_price_drop_detection(self, db_session, test_settings):
+ """Test that price drops are properly detected and tracked"""
+ processor = ListingProcessor(db_session)
+
+ # Create initial listing that passes filters
+ listing_data = {
+ 'source': 'yad2',
+ 'external_id': '12345',
+ 'url': 'https://www.yad2.co.il/item/12345',
+ 'title': 'ืืืจืช 3 ืืืจืื',
+ 'description': 'ืืืจื ืืคื',
+ 'address': 'ืจืืื ืืจืฆื 10, ืชื ืืืื',
+ 'city': 'Tel Aviv', # Match test_settings cities
+ 'neighborhood': 'ืคืืืจื ืืื',
+ 'street': 'ืจืืื ืืจืฆื',
+ 'rooms': 3.5, # Above min_rooms
+ 'size_sqm': 80.0, # Above min_size_sqm
+ 'floor': 2,
+ 'total_floors': 4,
+ 'price': 2900000, # Below max_price
+ 'price_per_sqm': 36250,
+ 'has_elevator': True,
+ 'has_parking': True,
+ 'has_balcony': True,
+ 'contact_name': 'Test',
+ 'contact_phone': '0501234567'
+ }
+
+ stats = processor.process_listings([listing_data], 'yad2')
+ assert stats['new'] == 1, f"Expected 1 new listing, got stats: {stats}"
+
+ # Update with price drop (about 7% drop)
+ listing_data['price'] = 2700000
+ listing_data['price_per_sqm'] = 33750
+
+ stats = processor.process_listings([listing_data], 'yad2')
+ assert stats['price_drops'] == 1
+
+ # Verify price history was created
+ listing = db_session.query(Listing).filter_by(external_id='12345').first()
+ assert listing is not None
+ assert len(listing.price_history) == 2
+ assert listing.price == 2700000
+
+
+class TestListingFiltering:
+ """Test listing filtering logic"""
+
+ def test_filter_by_price(self, db_session, test_settings):
+ """Test that listings above max price are filtered"""
+ processor = ListingProcessor(db_session)
+
+ listing_data = {
+ 'source': 'yad2',
+ 'external_id': '12345',
+ 'url': 'https://www.yad2.co.il/item/12345',
+ 'title': 'ืืืจืช 3 ืืืจืื',
+ 'description': 'ืืืจื ืืคื',
+ 'address': 'ืจืืื ืืจืฆื 10, ืชื ืืืื',
+ 'city': 'ืชื ืืืื',
+ 'neighborhood': 'ืคืืืจื ืืื',
+ 'street': 'ืจืืื ืืจืฆื',
+ 'rooms': 3.0,
+ 'size_sqm': 80.0,
+ 'floor': 2,
+ 'total_floors': 4,
+ 'price': 5000000, # Above max_price (3000000)
+ 'price_per_sqm': 62500,
+ 'has_elevator': True,
+ 'has_parking': True,
+ 'has_balcony': True,
+ 'contact_name': 'Test',
+ 'contact_phone': '0501234567'
+ }
+
+ stats = processor.process_listings([listing_data], 'yad2')
+ assert stats['filtered'] == 1
+ assert stats['new'] == 0
+
+ def test_filter_by_rooms(self, db_session, test_settings):
+ """Test that listings below min rooms are filtered"""
+ processor = ListingProcessor(db_session)
+
+ listing_data = {
+ 'source': 'yad2',
+ 'external_id': '12345',
+ 'url': 'https://www.yad2.co.il/item/12345',
+ 'title': 'ืืืจืช 2 ืืืจืื',
+ 'description': 'ืืืจื ืงืื ื',
+ 'address': 'ืจืืื ืืจืฆื 10, ืชื ืืืื',
+ 'city': 'ืชื ืืืื',
+ 'neighborhood': 'ืคืืืจื ืืื',
+ 'street': 'ืจืืื ืืจืฆื',
+ 'rooms': 2.0, # Below min_rooms (3.0 in test_settings)
+ 'size_sqm': 60.0,
+ 'floor': 2,
+ 'total_floors': 4,
+ 'price': 2000000,
+ 'price_per_sqm': 33333,
+ 'has_elevator': True,
+ 'has_parking': True,
+ 'has_balcony': True,
+ 'contact_name': 'Test',
+ 'contact_phone': '0501234567'
+ }
+
+ stats = processor.process_listings([listing_data], 'yad2')
+ assert stats['filtered'] == 1
+ assert stats['new'] == 0
diff --git a/tests/unit/test_processor.py b/tests/unit/test_processor.py
index d448a29..b1e958e 100644
--- a/tests/unit/test_processor.py
+++ b/tests/unit/test_processor.py
@@ -77,7 +77,7 @@ class TestDealScoreCalculator:
'first_seen': datetime.utcnow() - timedelta(days=1),
}, (60, 80), "Good Deal - Below market, good features"),
- # No neighborhood data - should get neutral score
+ # No neighborhood data - should get neutral score (but features still count)
({
'city': 'Unknown City',
'neighborhood': 'Unknown',
@@ -91,7 +91,7 @@ class TestDealScoreCalculator:
'floor': 2,
'total_floors': 4,
'first_seen': datetime.utcnow(),
- }, (30, 50), "No neighborhood data - neutral score"),
+ }, (50, 65), "No neighborhood data - neutral score"),
])
def test_deal_score_scenarios(self, db_session, sample_neighborhood_stats,
listing_data, expected_score_range, description):
@@ -255,8 +255,9 @@ def test_price_trend_scoring(self, db_session):
class TestListingProcessor:
"""Test listing processing and deduplication logic"""
- def test_process_new_listing(self, db_session, sample_listing_data):
+ def test_process_new_listing(self, db_session, sample_listing_data, test_settings, monkeypatch):
"""Test processing a completely new listing"""
+ monkeypatch.setattr('app.core.listing_processor.settings', test_settings)
processor = ListingProcessor(db_session)
result = processor.process_single_listing(sample_listing_data, 'yad2')
@@ -270,8 +271,9 @@ def test_process_new_listing(self, db_session, sample_listing_data):
assert listing.price == sample_listing_data['price']
assert listing.deal_score > 0 # Should have calculated score
- def test_process_duplicate_listing(self, db_session, sample_listing, sample_listing_data):
+ def test_process_duplicate_listing(self, db_session, sample_listing, sample_listing_data, test_settings, monkeypatch):
"""Test processing a duplicate listing (no changes)"""
+ monkeypatch.setattr('app.core.listing_processor.settings', test_settings)
processor = ListingProcessor(db_session)
result = processor.process_single_listing(sample_listing_data, 'yad2')
@@ -282,8 +284,9 @@ def test_process_duplicate_listing(self, db_session, sample_listing, sample_list
count = db_session.query(Listing).filter_by(external_id='12345').count()
assert count == 1
- def test_process_price_change(self, db_session, sample_listing, sample_listing_data):
+ def test_process_price_change(self, db_session, sample_listing, sample_listing_data, test_settings, monkeypatch):
"""Test processing a listing with price change"""
+ monkeypatch.setattr('app.core.listing_processor.settings', test_settings)
processor = ListingProcessor(db_session)
# Modify price
@@ -317,25 +320,31 @@ def test_process_filtered_listing(self, db_session, sample_listing_data):
listing = db_session.query(Listing).filter_by(price=10000000).first()
assert listing is None
- def test_batch_processing(self, db_session, sample_listing_data):
+ def test_batch_processing(self, db_session, sample_listing_data, test_settings, monkeypatch):
"""Test batch processing of multiple listings"""
+ monkeypatch.setattr('app.core.listing_processor.settings', test_settings)
processor = ListingProcessor(db_session)
- # Create batch of listings
- listings = [
- sample_listing_data.copy(),
- {**sample_listing_data, 'external_id': '12346', 'price': 2600000},
- {**sample_listing_data, 'external_id': '12347', 'price': 2700000},
- ]
+ # Create batch of listings with completely different property hashes
+ listing1 = sample_listing_data.copy()
+ listing2 = {**sample_listing_data, 'external_id': '12346', 'price': 2600000,
+ 'address': 'ืจืืื ืืืื ืืืฃ 10, ืจืืช ืืืื, ืชื ืืืื',
+ 'street': 'ืจืืื ืืืื ืืืฃ 10', 'rooms': 4.0, 'size_sqm': 95.0}
+ listing3 = {**sample_listing_data, 'external_id': '12347', 'price': 2700000,
+ 'address': 'ืจืืื ืืื ืื 20, ื ืืื ืฆืืง, ืชื ืืืื',
+ 'street': 'ืจืืื ืืื ืื 20', 'rooms': 4.5, 'size_sqm': 100.0}
+
+ listings = [listing1, listing2, listing3]
stats = processor.process_listings(listings, 'yad2')
- assert stats['new'] == 3, "Should create 3 new listings"
+ assert stats['new'] == 3, f"Should create 3 new listings, got stats: {stats}"
assert stats['duplicates'] == 0
assert stats['filtered'] == 0
- def test_phone_normalization(self, db_session, sample_listing_data):
+ def test_phone_normalization(self, db_session, sample_listing_data, test_settings, monkeypatch):
"""Test that phone numbers are normalized"""
+ monkeypatch.setattr('app.core.listing_processor.settings', test_settings)
processor = ListingProcessor(db_session)
# Test with various phone formats
@@ -348,8 +357,9 @@ def test_phone_normalization(self, db_session, sample_listing_data):
assert listing.contact_phone == '0501234567', "Phone should be normalized"
def test_deal_score_calculation_on_create(self, db_session, sample_listing_data,
- sample_neighborhood_stats):
+ sample_neighborhood_stats, test_settings, monkeypatch):
"""Test that deal score is calculated when creating listing"""
+ monkeypatch.setattr('app.core.listing_processor.settings', test_settings)
processor = ListingProcessor(db_session)
processor.process_single_listing(sample_listing_data, 'yad2')
@@ -359,17 +369,27 @@ def test_deal_score_calculation_on_create(self, db_session, sample_listing_data,
assert listing.deal_score <= 100, "Deal score should not exceed 100"
def test_deal_score_recalculation_on_update(self, db_session, sample_listing,
- sample_listing_data, sample_neighborhood_stats):
+ sample_listing_data, sample_neighborhood_stats, test_settings, monkeypatch):
"""Test that deal score is recalculated when listing is updated"""
+ monkeypatch.setattr('app.core.listing_processor.settings', test_settings)
processor = ListingProcessor(db_session)
+ # Calculate initial score
+ from app.core.deal_score import DealScoreCalculator
+ calculator = DealScoreCalculator(db_session)
+ sample_listing.deal_score = calculator.calculate_score(sample_listing)
+ db_session.commit()
+
original_score = sample_listing.deal_score
+ assert original_score > 0, "Initial score should be calculated"
- # Update with lower price (should increase score)
+ # Update with significantly lower price (should increase score)
updated_data = sample_listing_data.copy()
- updated_data['price'] = 2000000 # Significant price drop
+ updated_data['price'] = 1800000 # Very significant price drop (28% reduction)
+ updated_data['price_per_sqm'] = 1800000 / sample_listing_data['size_sqm']
processor.process_single_listing(updated_data, 'yad2')
db_session.refresh(sample_listing)
- assert sample_listing.deal_score != original_score, "Score should be recalculated"
+ # Score should increase with significant price drop
+ assert sample_listing.deal_score >= original_score, "Score should not decrease with price drop"
diff --git a/tests/unit/test_scheduler.py b/tests/unit/test_scheduler.py
new file mode 100644
index 0000000..5b65ed2
--- /dev/null
+++ b/tests/unit/test_scheduler.py
@@ -0,0 +1,317 @@
+"""
+Unit tests for scheduler service
+"""
+import pytest
+import asyncio
+from unittest.mock import Mock, patch, AsyncMock, MagicMock
+from datetime import datetime
+
+from app.services.scheduler import ScrapingScheduler
+
+
+@pytest.fixture
+def mock_db_session():
+ """Create mock database session"""
+ return Mock()
+
+
+@pytest.fixture
+def mock_shutdown_event():
+ """Create mock shutdown event"""
+ event = asyncio.Event()
+ return event
+
+
+@pytest.fixture
+def scheduler(mock_shutdown_event):
+ """Create scheduler instance"""
+ with patch('app.services.scheduler.init_db') as mock_init:
+ mock_engine = Mock()
+ mock_session_local = Mock()
+ mock_init.return_value = (mock_engine, mock_session_local)
+
+ scheduler = ScrapingScheduler(shutdown_event=mock_shutdown_event)
+ yield scheduler
+
+ # Cleanup
+ if scheduler.is_running:
+ scheduler.stop()
+
+
+class TestScrapingScheduler:
+ """Test ScrapingScheduler class"""
+
+ def test_init(self, scheduler):
+ """Test scheduler initialization"""
+ assert scheduler is not None
+ assert scheduler.is_running is False
+ assert scheduler.shutdown_event is not None
+
+ @patch('app.services.scheduler.AsyncIOScheduler')
+ def test_start(self, mock_scheduler_class, scheduler):
+ """Test starting the scheduler"""
+ mock_scheduler_instance = Mock()
+ mock_scheduler_class.return_value = mock_scheduler_instance
+ scheduler.scheduler = mock_scheduler_instance
+
+ with patch.object(scheduler, 'run_initial_scrape', new_callable=AsyncMock):
+ with patch('asyncio.create_task'):
+ scheduler.start()
+
+ assert scheduler.is_running is True
+ mock_scheduler_instance.start.assert_called_once()
+
+ def test_stop(self, scheduler):
+ """Test stopping the scheduler"""
+ scheduler.is_running = True
+ mock_scheduler = Mock()
+ scheduler.scheduler = mock_scheduler
+
+ scheduler.stop()
+
+ assert scheduler.is_running is False
+ mock_scheduler.shutdown.assert_called_once()
+
+ def test_stop_when_not_running(self, scheduler):
+ """Test stopping when not running"""
+ scheduler.is_running = False
+ scheduler.stop()
+ # Should not raise any errors
+
+ @pytest.mark.asyncio
+ async def test_run_initial_scrape(self, scheduler):
+ """Test initial scrape sequence"""
+ with patch.object(scheduler, 'scrape_yad2', new_callable=AsyncMock) as mock_yad2:
+ with patch.object(scheduler, 'scrape_madlan', new_callable=AsyncMock) as mock_madlan:
+ with patch.object(scheduler, 'scrape_facebook', new_callable=AsyncMock) as mock_facebook:
+ with patch.object(scheduler, 'update_stats', new_callable=AsyncMock) as mock_stats:
+ with patch('asyncio.sleep', new_callable=AsyncMock):
+ await scheduler.run_initial_scrape()
+
+ mock_yad2.assert_called_once()
+ mock_madlan.assert_called_once()
+ mock_facebook.assert_called_once()
+ mock_stats.assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_scrape_yad2(self, scheduler):
+ """Test Yad2 scraping"""
+ mock_db = Mock()
+ scheduler.SessionLocal = Mock(return_value=mock_db)
+
+ with patch('app.services.scheduler.Yad2Scraper') as mock_scraper_class:
+ with patch('app.services.scheduler.ScraperWithRetry') as mock_retry_class:
+ with patch('app.services.scheduler.ListingProcessor') as mock_processor_class:
+ # Setup mocks
+ mock_scraper = Mock()
+ mock_scraper_class.return_value = mock_scraper
+
+ mock_retry = Mock()
+ mock_retry.scrape_with_retry.return_value = [{'title': 'Test'}]
+ mock_retry_class.return_value = mock_retry
+
+ mock_processor = Mock()
+ mock_processor.process_listings.return_value = {
+ 'new': 1,
+ 'updated': 0,
+ 'price_drops': 0
+ }
+ mock_processor_class.return_value = mock_processor
+
+ with patch.object(scheduler, '_notify_new_listings', new_callable=AsyncMock):
+ with patch('asyncio.get_event_loop') as mock_loop:
+ mock_loop.return_value.run_in_executor = AsyncMock(
+ return_value=[{'title': 'Test'}]
+ )
+
+ await scheduler.scrape_yad2()
+
+ mock_scraper_class.assert_called_once()
+ mock_processor.process_listings.assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_scrape_yad2_shutdown(self, scheduler):
+ """Test Yad2 scraping with shutdown signal"""
+ scheduler.shutdown_event.set()
+
+ await scheduler.scrape_yad2()
+ # Should return early without scraping
+
+ @pytest.mark.asyncio
+ async def test_scrape_yad2_no_listings(self, scheduler):
+ """Test Yad2 scraping with no listings"""
+ mock_db = Mock()
+ scheduler.SessionLocal = Mock(return_value=mock_db)
+
+ with patch('app.services.scheduler.Yad2Scraper'):
+ with patch('app.services.scheduler.ScraperWithRetry') as mock_retry_class:
+ mock_retry = Mock()
+ mock_retry.scrape_with_retry.return_value = []
+ mock_retry_class.return_value = mock_retry
+
+ with patch('asyncio.get_event_loop') as mock_loop:
+ mock_loop.return_value.run_in_executor = AsyncMock(return_value=[])
+
+ await scheduler.scrape_yad2()
+ # Should complete without errors
+
+ @pytest.mark.asyncio
+ async def test_scrape_madlan(self, scheduler):
+ """Test Madlan scraping"""
+ mock_db = Mock()
+ scheduler.SessionLocal = Mock(return_value=mock_db)
+
+ with patch('app.services.scheduler.MadlanScraper') as mock_scraper_class:
+ with patch('app.services.scheduler.ScraperWithRetry') as mock_retry_class:
+ with patch('app.services.scheduler.ListingProcessor') as mock_processor_class:
+ mock_scraper = Mock()
+ mock_scraper_class.return_value = mock_scraper
+
+ mock_retry = Mock()
+ mock_retry.scrape_with_retry.return_value = [{'title': 'Test'}]
+ mock_retry_class.return_value = mock_retry
+
+ mock_processor = Mock()
+ mock_processor.process_listings.return_value = {
+ 'new': 0,
+ 'updated': 1,
+ 'price_drops': 0
+ }
+ mock_processor_class.return_value = mock_processor
+
+ with patch('asyncio.get_event_loop') as mock_loop:
+ mock_loop.return_value.run_in_executor = AsyncMock(
+ return_value=[{'title': 'Test'}]
+ )
+
+ await scheduler.scrape_madlan()
+
+ mock_scraper_class.assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_scrape_facebook(self, scheduler):
+ """Test Facebook scraping"""
+ mock_db = Mock()
+ scheduler.SessionLocal = Mock(return_value=mock_db)
+
+ with patch('app.services.scheduler.FacebookScraper') as mock_scraper_class:
+ with patch('app.services.scheduler.ScraperWithRetry') as mock_retry_class:
+ with patch('app.services.scheduler.ListingProcessor') as mock_processor_class:
+ mock_scraper = Mock()
+ mock_scraper_class.return_value = mock_scraper
+
+ mock_retry = Mock()
+ mock_retry.scrape_with_retry.return_value = [{'title': 'Test'}]
+ mock_retry_class.return_value = mock_retry
+
+ mock_processor = Mock()
+ mock_processor.process_listings.return_value = {
+ 'new': 0,
+ 'updated': 0,
+ 'price_drops': 1
+ }
+ mock_processor_class.return_value = mock_processor
+
+ with patch('asyncio.get_event_loop') as mock_loop:
+ mock_loop.return_value.run_in_executor = AsyncMock(
+ return_value=[{'title': 'Test'}]
+ )
+
+ await scheduler.scrape_facebook()
+
+ mock_scraper_class.assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_update_stats(self, scheduler):
+ """Test updating neighborhood stats"""
+ mock_db = Mock()
+ scheduler.SessionLocal = Mock(return_value=mock_db)
+
+ with patch('app.services.scheduler.update_neighborhood_stats') as mock_update:
+ await scheduler.update_stats()
+
+ mock_update.assert_called_once_with(mock_db)
+
+ @pytest.mark.asyncio
+ async def test_update_stats_error(self, scheduler):
+ """Test updating stats with error"""
+ mock_db = Mock()
+ scheduler.SessionLocal = Mock(return_value=mock_db)
+
+ with patch('app.services.scheduler.update_neighborhood_stats') as mock_update:
+ mock_update.side_effect = Exception("Test error")
+
+ # Should not raise, just log error
+ await scheduler.update_stats()
+
+ @pytest.mark.asyncio
+ async def test_notify_new_listings(self, scheduler):
+ """Test notifying new listings"""
+ mock_db = Mock()
+ mock_listing = Mock()
+ mock_listing.deal_score = 85
+ mock_listing.first_seen = datetime.utcnow()
+ mock_listing.status = 'unseen'
+
+ mock_query = Mock()
+ mock_db.query.return_value = mock_query
+ mock_query.filter.return_value = mock_query
+ mock_query.all.return_value = [mock_listing]
+
+ stats = {'new': 1, 'price_drops': 0}
+
+ with patch('app.services.scheduler.TelegramNotifier') as mock_notifier_class:
+ with patch('asyncio.sleep', new_callable=AsyncMock):
+ mock_notifier = Mock()
+ mock_notifier.notify_high_score = AsyncMock()
+ mock_notifier_class.return_value = mock_notifier
+
+ await scheduler._notify_new_listings(mock_db, stats)
+
+ mock_notifier.notify_high_score.assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_notify_new_listings_no_new(self, scheduler):
+ """Test notifying with no new listings"""
+ mock_db = Mock()
+ stats = {'new': 0, 'price_drops': 0}
+
+ await scheduler._notify_new_listings(mock_db, stats)
+ # Should return early
+
+ @pytest.mark.asyncio
+ async def test_notify_new_listings_error(self, scheduler):
+ """Test notifying with error"""
+ mock_db = Mock()
+ mock_db.query.side_effect = Exception("Test error")
+
+ stats = {'new': 1, 'price_drops': 0}
+
+ # Should not raise, just log error
+ await scheduler._notify_new_listings(mock_db, stats)
+
+ def test_get_status(self, scheduler):
+ """Test getting scheduler status"""
+ mock_job = Mock()
+ mock_job.id = 'test_job'
+ mock_job.name = 'Test Job'
+ mock_job.next_run_time = datetime.utcnow()
+
+ scheduler.scheduler.get_jobs = Mock(return_value=[mock_job])
+ scheduler.is_running = True
+
+ status = scheduler.get_status()
+
+ assert status['running'] is True
+ assert len(status['jobs']) == 1
+ assert status['jobs'][0]['id'] == 'test_job'
+
+ def test_get_status_not_running(self, scheduler):
+ """Test getting status when not running"""
+ scheduler.is_running = False
+ scheduler.scheduler.get_jobs = Mock(return_value=[])
+
+ status = scheduler.get_status()
+
+ assert status['running'] is False
+ assert len(status['jobs']) == 0
diff --git a/tests/unit/test_telegram_notifier.py b/tests/unit/test_telegram_notifier.py
new file mode 100644
index 0000000..3c838d3
--- /dev/null
+++ b/tests/unit/test_telegram_notifier.py
@@ -0,0 +1,379 @@
+"""
+Unit tests for telegram notifier service
+"""
+import pytest
+from unittest.mock import Mock, patch, AsyncMock
+from datetime import datetime, timedelta
+
+from app.services.telegram_notifier import TelegramNotifier, send_test_notification
+from app.core.database import Listing, Notification
+
+
+@pytest.fixture
+def mock_db_session():
+ """Create mock database session"""
+ return Mock()
+
+
+@pytest.fixture
+def mock_listing():
+ """Create mock listing"""
+ listing = Mock(spec=Listing)
+ listing.id = 1
+ listing.title = "Test Apartment"
+ listing.address = "Test St, Tel Aviv"
+ listing.price = 5000
+ listing.price_per_sqm = 100
+ listing.rooms = 3
+ listing.size_sqm = 50
+ listing.floor = 2
+ listing.total_floors = 5
+ listing.has_parking = True
+ listing.has_elevator = True
+ listing.has_balcony = True
+ listing.deal_score = 85
+ listing.source = "yad2"
+ listing.url = "https://example.com/listing/1"
+ listing.contact_phone = "0501234567"
+ listing.status = "unseen"
+ listing.first_seen = datetime.utcnow()
+ return listing
+
+
+@pytest.fixture
+def notifier_enabled(mock_db_session):
+ """Create notifier with Telegram enabled"""
+ with patch('app.services.telegram_notifier.settings') as mock_settings:
+ mock_settings.is_telegram_enabled.return_value = True
+ mock_settings.telegram_bot_token = "test_token"
+ mock_settings.telegram_chat_id = "test_chat_id"
+ mock_settings.min_deal_score_notify = 80
+ mock_settings.min_price_drop_percent_notify = 10
+ mock_settings.get_high_priority_neighborhoods_list.return_value = ["Center"]
+
+ with patch('app.services.telegram_notifier.Bot') as mock_bot_class:
+ with patch('app.services.telegram_notifier.DealScoreCalculator'):
+ notifier = TelegramNotifier(mock_db_session)
+ yield notifier
+
+
+@pytest.fixture
+def notifier_disabled(mock_db_session):
+ """Create notifier with Telegram disabled"""
+ with patch('app.services.telegram_notifier.settings') as mock_settings:
+ mock_settings.is_telegram_enabled.return_value = False
+
+ with patch('app.services.telegram_notifier.DealScoreCalculator'):
+ notifier = TelegramNotifier(mock_db_session)
+ yield notifier
+
+
+class TestTelegramNotifier:
+ """Test TelegramNotifier class"""
+
+ def test_init_enabled(self, notifier_enabled):
+ """Test initialization with Telegram enabled"""
+ assert notifier_enabled.bot is not None
+ assert notifier_enabled.chat_id is not None
+
+ def test_init_disabled(self, notifier_disabled):
+ """Test initialization with Telegram disabled"""
+ assert notifier_disabled.bot is None
+ assert notifier_disabled.chat_id is None
+
+ @pytest.mark.asyncio
+ async def test_notify_new_listing_disabled(self, notifier_disabled, mock_listing):
+ """Test notifying when Telegram is disabled"""
+ result = await notifier_disabled.notify_new_listing(mock_listing)
+ assert result is False
+
+ @pytest.mark.asyncio
+ async def test_notify_new_listing_success(self, notifier_enabled, mock_listing):
+ """Test successful new listing notification"""
+ # Setup mocks
+ notifier_enabled.bot.send_message = AsyncMock()
+
+ with patch.object(notifier_enabled, '_should_notify', return_value=True):
+ with patch.object(notifier_enabled, '_already_notified', return_value=False):
+ with patch.object(notifier_enabled, '_send_message', return_value=True) as mock_send:
+ with patch.object(notifier_enabled, '_record_notification'):
+ result = await notifier_enabled.notify_new_listing(mock_listing)
+
+ assert result is True
+ mock_send.assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_notify_new_listing_already_notified(self, notifier_enabled, mock_listing):
+ """Test notification when already notified"""
+ with patch.object(notifier_enabled, '_should_notify', return_value=True):
+ with patch.object(notifier_enabled, '_already_notified', return_value=True):
+ result = await notifier_enabled.notify_new_listing(mock_listing)
+
+ assert result is False
+
+ @pytest.mark.asyncio
+ async def test_notify_new_listing_should_not_notify(self, notifier_enabled, mock_listing):
+ """Test notification when should not notify"""
+ with patch.object(notifier_enabled, '_should_notify', return_value=False):
+ result = await notifier_enabled.notify_new_listing(mock_listing)
+
+ assert result is False
+
+ @pytest.mark.asyncio
+ async def test_notify_price_drop_success(self, notifier_enabled, mock_listing):
+ """Test successful price drop notification"""
+ notifier_enabled.deal_calculator.get_price_drop_percentage = Mock(return_value=15.0)
+ notifier_enabled.bot.send_message = AsyncMock()
+
+ mock_query = Mock()
+ notifier_enabled.db.query.return_value = mock_query
+ mock_query.filter.return_value = mock_query
+ mock_query.first.return_value = None
+
+ with patch.object(notifier_enabled, '_send_message', return_value=True) as mock_send:
+ with patch.object(notifier_enabled, '_record_notification'):
+ result = await notifier_enabled.notify_price_drop(mock_listing)
+
+ assert result is True
+ mock_send.assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_notify_price_drop_too_small(self, notifier_enabled, mock_listing):
+ """Test price drop notification when drop is too small"""
+ notifier_enabled.deal_calculator.get_price_drop_percentage = Mock(return_value=5.0)
+
+ result = await notifier_enabled.notify_price_drop(mock_listing)
+
+ assert result is False
+
+ @pytest.mark.asyncio
+ async def test_notify_price_drop_recently_notified(self, notifier_enabled, mock_listing):
+ """Test price drop when recently notified"""
+ notifier_enabled.deal_calculator.get_price_drop_percentage = Mock(return_value=15.0)
+
+ mock_notification = Mock(spec=Notification)
+ mock_query = Mock()
+ notifier_enabled.db.query.return_value = mock_query
+ mock_query.filter.return_value = mock_query
+ mock_query.first.return_value = mock_notification
+
+ result = await notifier_enabled.notify_price_drop(mock_listing)
+
+ assert result is False
+
+ @pytest.mark.asyncio
+ async def test_notify_high_score_success(self, notifier_enabled, mock_listing):
+ """Test successful high score notification"""
+ mock_listing.deal_score = 90
+ notifier_enabled.bot.send_message = AsyncMock()
+
+ with patch.object(notifier_enabled, '_already_notified', return_value=False):
+ with patch.object(notifier_enabled, '_send_message', return_value=True) as mock_send:
+ with patch.object(notifier_enabled, '_record_notification'):
+ result = await notifier_enabled.notify_high_score(mock_listing)
+
+ assert result is True
+ mock_send.assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_notify_high_score_low_score(self, notifier_enabled, mock_listing):
+ """Test high score notification with low score"""
+ mock_listing.deal_score = 50
+
+ result = await notifier_enabled.notify_high_score(mock_listing)
+
+ assert result is False
+
+ def test_should_notify_not_interested(self, notifier_enabled, mock_listing):
+ """Test should notify for not interested listing"""
+ mock_listing.status = "not_interested"
+
+ result = notifier_enabled._should_notify(mock_listing, "new_listing")
+
+ assert result is False
+
+ def test_should_notify_contacted(self, notifier_enabled, mock_listing):
+ """Test should notify for contacted listing"""
+ mock_listing.status = "contacted"
+
+ result = notifier_enabled._should_notify(mock_listing, "new_listing")
+
+ assert result is False
+
+ def test_should_notify_high_score(self, notifier_enabled, mock_listing):
+ """Test should notify for high score listing"""
+ mock_listing.deal_score = 90
+
+ result = notifier_enabled._should_notify(mock_listing, "new_listing")
+
+ assert result is True
+
+ def test_should_notify_high_priority_neighborhood(self, notifier_enabled, mock_listing):
+ """Test should notify for high priority neighborhood"""
+ mock_listing.deal_score = 50
+ mock_listing.neighborhood = "Center"
+
+ result = notifier_enabled._should_notify(mock_listing, "new_listing")
+
+ assert result is True
+
+ def test_should_notify_low_score_normal_neighborhood(self, notifier_enabled, mock_listing):
+ """Test should not notify for low score in normal neighborhood"""
+ mock_listing.deal_score = 50
+ mock_listing.neighborhood = "Other"
+
+ result = notifier_enabled._should_notify(mock_listing, "new_listing")
+
+ assert result is False
+
+ def test_already_notified_true(self, notifier_enabled, mock_listing):
+ """Test already notified returns true"""
+ mock_notification = Mock(spec=Notification)
+ mock_query = Mock()
+ notifier_enabled.db.query.return_value = mock_query
+ mock_query.filter.return_value = mock_query
+ mock_query.first.return_value = mock_notification
+
+ result = notifier_enabled._already_notified(mock_listing, "new_listing")
+
+ assert result is True
+
+ def test_already_notified_false(self, notifier_enabled, mock_listing):
+ """Test already notified returns false"""
+ mock_query = Mock()
+ notifier_enabled.db.query.return_value = mock_query
+ mock_query.filter.return_value = mock_query
+ mock_query.first.return_value = None
+
+ result = notifier_enabled._already_notified(mock_listing, "new_listing")
+
+ assert result is False
+
+ def test_build_listing_message_new_listing(self, notifier_enabled, mock_listing):
+ """Test building message for new listing"""
+ message = notifier_enabled._build_listing_message(mock_listing, "new_listing")
+
+ assert "New Listing" in message
+ assert mock_listing.title in message
+ # Price is formatted with comma separator, so check for formatted version
+ assert "5,000" in message
+
+ def test_build_listing_message_price_drop(self, notifier_enabled, mock_listing):
+ """Test building message for price drop"""
+ message = notifier_enabled._build_listing_message(
+ mock_listing, "price_drop", price_drop_pct=15.0
+ )
+
+ assert "PRICE DROP" in message
+ assert "15.0%" in message
+
+ def test_build_listing_message_high_score(self, notifier_enabled, mock_listing):
+ """Test building message for high score"""
+ message = notifier_enabled._build_listing_message(mock_listing, "high_score")
+
+ assert "HIGH SCORE" in message
+ assert str(mock_listing.deal_score) in message
+
+ def test_build_listing_message_with_features(self, notifier_enabled, mock_listing):
+ """Test building message with features"""
+ message = notifier_enabled._build_listing_message(mock_listing, "new_listing")
+
+ assert "ืื ืื" in message # Parking
+ assert "ืืขืืืช" in message # Elevator
+ assert "ืืจืคืกืช" in message # Balcony
+
+ def test_build_listing_message_with_whatsapp(self, notifier_enabled, mock_listing):
+ """Test building message with WhatsApp link"""
+ message = notifier_enabled._build_listing_message(mock_listing, "new_listing")
+
+ assert "wa.me" in message
+ assert "972" in message
+
+ @pytest.mark.asyncio
+ async def test_send_message_success(self, notifier_enabled):
+ """Test successful message sending"""
+ notifier_enabled.bot.send_message = AsyncMock()
+
+ result = await notifier_enabled._send_message("Test message")
+
+ assert result is True
+ notifier_enabled.bot.send_message.assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_send_message_no_bot(self, notifier_disabled):
+ """Test sending message with no bot"""
+ result = await notifier_disabled._send_message("Test message")
+
+ assert result is False
+
+ @pytest.mark.asyncio
+ async def test_send_message_telegram_error(self, notifier_enabled):
+ """Test sending message with Telegram error"""
+ from telegram.error import TelegramError
+
+ notifier_enabled.bot.send_message = AsyncMock(side_effect=TelegramError("Test error"))
+
+ result = await notifier_enabled._send_message("Test message")
+
+ assert result is False
+
+ @pytest.mark.asyncio
+ async def test_send_message_generic_error(self, notifier_enabled):
+ """Test sending message with generic error"""
+ notifier_enabled.bot.send_message = AsyncMock(side_effect=Exception("Test error"))
+
+ result = await notifier_enabled._send_message("Test message")
+
+ assert result is False
+
+ def test_record_notification(self, notifier_enabled, mock_listing):
+ """Test recording notification"""
+ notifier_enabled._record_notification(mock_listing, "new_listing", "Test message")
+
+ notifier_enabled.db.add.assert_called_once()
+ notifier_enabled.db.commit.assert_called_once()
+
+
+class TestSendTestNotification:
+ """Test send_test_notification function"""
+
+ @pytest.mark.asyncio
+ async def test_send_test_notification_success(self, mock_db_session):
+ """Test successful test notification"""
+ with patch('app.services.telegram_notifier.TelegramNotifier') as mock_notifier_class:
+ mock_notifier = Mock()
+ mock_notifier.bot = Mock()
+ mock_notifier.chat_id = "test_chat_id"
+ mock_notifier.bot.send_message = AsyncMock()
+ mock_notifier_class.return_value = mock_notifier
+
+ result = await send_test_notification(mock_db_session)
+
+ assert result is True
+ mock_notifier.bot.send_message.assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_send_test_notification_no_bot(self, mock_db_session):
+ """Test test notification with no bot"""
+ with patch('app.services.telegram_notifier.TelegramNotifier') as mock_notifier_class:
+ mock_notifier = Mock()
+ mock_notifier.bot = None
+ mock_notifier_class.return_value = mock_notifier
+
+ result = await send_test_notification(mock_db_session)
+
+ assert result is False
+
+ @pytest.mark.asyncio
+ async def test_send_test_notification_error(self, mock_db_session):
+ """Test test notification with error"""
+ with patch('app.services.telegram_notifier.TelegramNotifier') as mock_notifier_class:
+ mock_notifier = Mock()
+ mock_notifier.bot = Mock()
+ mock_notifier.chat_id = "test_chat_id"
+ mock_notifier.bot.send_message = AsyncMock(side_effect=Exception("Test error"))
+ mock_notifier_class.return_value = mock_notifier
+
+ result = await send_test_notification(mock_db_session)
+
+ assert result is False