diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..a063705 --- /dev/null +++ b/TESTING.md @@ -0,0 +1,152 @@ +# Testing Strategy + +## Overview + +Nadlan-MCP uses a three-tier testing approach: +1. **Fast unit tests** - Cached fixtures, run in <1s (default) +2. **E2E smoke tests** - Minimal API calls, run in ~5s (verify API works) +3. **Comprehensive E2E tests** - Full API coverage, run in ~5min (optional) + +## Running Tests + +### Default: Fast Unit Tests Only +```bash +pytest tests/ --ignore=tests/e2e/ +# Result: 180 passed, 1 skipped in 0.39s +``` + +### E2E Smoke Tests (Fast) +```bash +pytest tests/e2e/test_mcp_tools.py -m integration +# Result: 4 passed in 4.77s +``` + +### Comprehensive E2E Tests (Slow - Optional) +```bash +pytest tests/e2e/test_mcp_tools_comprehensive.py -m integration +# Result: 11 passed in 5m36s +``` + +### All Tests +```bash +pytest tests/ +``` + +## Test Structure + +### Fast Unit Tests (`tests/`) +- **174 tests** covering all core functionality +- Use cached API responses from `tests/fixtures/` +- Mock `GovmapClient` to avoid real API calls +- Run in **0.39 seconds** + +Key test files: +- `tests/govmap/test_models.py` - Pydantic model validation (36 tests) +- `tests/govmap/test_utils.py` - Helper functions (45 tests) +- `tests/govmap/test_validators.py` - Input validation (24 tests) +- `tests/test_govmap_client.py` - Client and business logic (41 tests) +- `tests/test_fastmcp_tools.py` - MCP tool integration (22 tests) +- `tests/test_mcp_tools_fast.py` - Fast MCP tool tests with fixtures (6 tests) + +### E2E Smoke Tests (`tests/e2e/test_mcp_tools.py`) +- **4 minimal smoke tests** with real API calls +- Verify API connectivity and basic functionality +- Use minimal data (small limits, small radius) +- Run in **4.77 seconds** + +### Comprehensive E2E Tests (`tests/e2e/test_mcp_tools_comprehensive.py`) +- **11 thorough integration tests** with real API calls +- Test all 10 MCP tools with full data +- Verify complete workflow from MCP tool → API → response +- Catch API contract changes +- Run in **5min 36sec** (optional, use for releases) + +## Fixtures + +Cached API responses in `tests/fixtures/`: +- `autocomplete_response.json` - Address search results +- `street_deals.json` - Street-level deal data +- `neighborhood_deals.json` - Neighborhood-level deal data +- `polygon_metadata.json` - Polygon metadata from radius search + +These fixtures are generated from real API calls and updated as needed. + +## Test Markers + +Configured in `pytest.ini`: +- `@pytest.mark.integration` - Slow tests requiring real API calls +- `@pytest.mark.unit` - Fast unit tests (default) + +## Performance Comparison + +| Test Suite | Count | Duration | Speed | +|------------|-------|----------|-------| +| Fast unit tests | 180 | 0.39s | 462 tests/sec | +| E2E smoke tests | 4 | 4.77s | 0.84 tests/sec | +| Comprehensive E2E | 11 | 5m36s | 0.033 tests/sec | + +**Unit tests are 12x faster than smoke tests, 860x faster than comprehensive E2E!** + +## CI/CD Recommendations + +### PR Checks (~5s) +```bash +# Run unit tests + smoke tests +pytest tests/ --ignore=tests/e2e/test_mcp_tools_comprehensive.py +``` + +### Nightly Build (~6min) +```bash +# Run all tests including comprehensive E2E +pytest tests/ +``` + +### Quick Verification (<5s) +```bash +# Just smoke tests to verify API works +pytest tests/e2e/test_mcp_tools.py -m integration +``` + +## Updating Fixtures + +When API responses change, regenerate fixtures: + +```bash +source venv/bin/activate +python << 'EOF' +import json +from nadlan_mcp.govmap import GovmapClient + +client = GovmapClient() + +# Update autocomplete fixture +autocomplete_response = client.autocomplete_address("חולון סוקולוב") +with open("tests/fixtures/autocomplete_response.json", "w", encoding="utf-8") as f: + json.dump([r.model_dump() for r in autocomplete_response.results], f, ensure_ascii=False, indent=2) + +# Update street deals +street_deals = client.get_street_deals("52385050", limit=10) +with open("tests/fixtures/street_deals.json", "w", encoding="utf-8") as f: + json.dump([d.model_dump(mode='json') for d in street_deals], f, ensure_ascii=False, indent=2) + +# Update neighborhood deals +neighborhood_deals = client.get_neighborhood_deals("52385050", limit=10) +with open("tests/fixtures/neighborhood_deals.json", "w", encoding="utf-8") as f: + json.dump([d.model_dump(mode='json') for d in neighborhood_deals], f, ensure_ascii=False, indent=2) + +# Update polygon metadata +polygons = client.get_deals_by_radius((3870928.84, 3766290.19), radius=500) +with open("tests/fixtures/polygon_metadata.json", "w", encoding="utf-8") as f: + json.dump(polygons[:10], f, ensure_ascii=False, indent=2) + +print("✓ Fixtures updated") +EOF +``` + +## Test Coverage + +Run with coverage reporting: +```bash +pytest --cov=nadlan_mcp --cov-report=html +open htmlcov/index.html +``` diff --git a/tests/e2e/__init__.py b/tests/e2e/__init__.py new file mode 100644 index 0000000..d2dd451 --- /dev/null +++ b/tests/e2e/__init__.py @@ -0,0 +1 @@ +"""End-to-end tests for MCP tools.""" diff --git a/tests/e2e/test_mcp_tools.py b/tests/e2e/test_mcp_tools.py new file mode 100644 index 0000000..6e61f3a --- /dev/null +++ b/tests/e2e/test_mcp_tools.py @@ -0,0 +1,72 @@ +""" +Fast E2E smoke tests for MCP tools (<30 seconds). + +These tests make MINIMAL real API calls to verify the service is working. +For comprehensive E2E testing, see test_mcp_tools_comprehensive.py + +Target: Complete in <30 seconds +""" +import json +import pytest +from nadlan_mcp.fastmcp_server import ( + autocomplete_address, + find_recent_deals_for_address, + get_street_deals, + get_deals_by_radius, +) + + +@pytest.mark.integration +class TestMCPToolsSmokeTests: + """Minimal E2E smoke tests to verify API connectivity.""" + + # Known working data + TEST_ADDRESS = "סוקולוב 38 חולון" + TEST_POLYGON_ID = "52385050" + TEST_LAT = 3766290.19 + TEST_LON = 3870928.84 + + def test_autocomplete_works(self): + """Smoke test: Autocomplete returns results.""" + result = autocomplete_address("חולון") + data = json.loads(result) + + assert isinstance(data, list) + assert len(data) > 0 + assert "text" in data[0] + + def test_get_street_deals_works(self): + """Smoke test: Can fetch street deals.""" + # Use small limit for speed + result = get_street_deals(self.TEST_POLYGON_ID, limit=5, deal_type=2) + data = json.loads(result) + + assert "total_deals" in data + assert data["total_deals"] > 0 + assert "deals" in data + + def test_get_deals_by_radius_works(self): + """Smoke test: Can fetch polygon metadata by radius.""" + # Use small radius for speed + result = get_deals_by_radius(self.TEST_LAT, self.TEST_LON, radius_meters=100) + data = json.loads(result) + + assert "total_polygons" in data + assert data["total_polygons"] > 0 + + def test_find_recent_deals_minimal(self): + """Smoke test: Main tool works with minimal data.""" + # Use very small limits to speed up + result = find_recent_deals_for_address( + self.TEST_ADDRESS, + years_back=1, + radius_meters=30, + max_deals=10 + ) + data = json.loads(result) + + # Just verify structure, don't check counts + assert "search_parameters" in data + assert "market_statistics" in data + assert "deals" in data + assert isinstance(data["deals"], list) diff --git a/tests/e2e/test_mcp_tools_comprehensive.py b/tests/e2e/test_mcp_tools_comprehensive.py new file mode 100644 index 0000000..7bff100 --- /dev/null +++ b/tests/e2e/test_mcp_tools_comprehensive.py @@ -0,0 +1,185 @@ +""" +End-to-end tests for all MCP tools. + +These tests make real API calls to verify the complete functionality +of each MCP tool from end to end. + +Mark as integration tests since they hit real APIs. +""" +import json +import pytest +from nadlan_mcp.fastmcp_server import ( + autocomplete_address, + find_recent_deals_for_address, + analyze_market_trends, + get_valuation_comparables, + get_deal_statistics, + get_market_activity_metrics, + compare_addresses, + get_street_deals, + get_neighborhood_deals, + get_deals_by_radius, +) + + +@pytest.mark.integration +class TestMCPToolsE2E: + """End-to-end tests for all 10 MCP tools.""" + + # Test addresses + TEST_ADDRESS_1 = "סוקולוב 38 חולון" + TEST_ADDRESS_2 = "דיזנגוף 50 תל אביב" + TEST_POLYGON_ID = "52385050" # Known polygon with data + # ITM coordinates for Holon (lat, lon format for MCP tool) + TEST_LAT = 3766290.19 + TEST_LON = 3870928.84 + + def test_autocomplete_address(self): + """Test address autocomplete returns results.""" + result = autocomplete_address("חולון סוקולוב") + data = json.loads(result) + + assert isinstance(data, list) + assert len(data) > 0 + + # Check first result has expected structure + first = data[0] + assert "text" in first + assert "coordinates" in first or "id" in first + + def test_find_recent_deals_for_address(self): + """Test finding recent deals for an address.""" + result = find_recent_deals_for_address(self.TEST_ADDRESS_1, max_deals=100) + data = json.loads(result) + + # Check response structure + assert "search_parameters" in data + assert "market_statistics" in data + assert "deals" in data + + # Verify some deals were found + assert isinstance(data["deals"], list) + assert len(data["deals"]) > 0 + + # Check deal structure + deal = data["deals"][0] + assert "deal_amount" in deal + assert "deal_date" in deal + assert "settlement_name_heb" in deal + + def test_analyze_market_trends(self): + """Test market trend analysis.""" + result = analyze_market_trends( + self.TEST_ADDRESS_1, years_back=3, radius_meters=100 + ) + data = json.loads(result) + + # Check response structure + assert "market_summary" in data + assert "total_deals" in data["market_summary"] + assert isinstance(data["market_summary"]["total_deals"], int) + assert data["market_summary"]["total_deals"] >= 0 + + def test_get_valuation_comparables(self): + """Test getting valuation comparables.""" + result = get_valuation_comparables( + self.TEST_ADDRESS_1, + years_back=3, + min_rooms=3.0, + max_rooms=5.0 + ) + data = json.loads(result) + + assert "total_comparables" in data + assert isinstance(data["total_comparables"], int) + assert data["total_comparables"] >= 0 + + if data["total_comparables"] > 0: + assert "comparables" in data + comp = data["comparables"][0] + assert "deal_amount" in comp + assert "asset_room_num" in comp + + def test_get_deal_statistics(self): + """Test deal statistics calculation.""" + result = get_deal_statistics(self.TEST_ADDRESS_1, years_back=3) + data = json.loads(result) + + # Check response structure + assert "statistics" in data + if "sample_size" in data["statistics"]: + assert isinstance(data["statistics"]["sample_size"], int) + assert data["statistics"]["sample_size"] >= 0 + + def test_get_market_activity_metrics(self): + """Test market activity metrics.""" + result = get_market_activity_metrics(self.TEST_ADDRESS_1, years_back=3) + data = json.loads(result) + + # Check response structure + assert "market_activity" in data + if data["market_activity"] is not None: + assert "activity_score" in data["market_activity"] + + def test_compare_addresses(self): + """Test comparing multiple addresses.""" + result = compare_addresses([self.TEST_ADDRESS_1, self.TEST_ADDRESS_2]) + data = json.loads(result) + + assert isinstance(data, dict) + assert "addresses_compared" in data + assert "all_results" in data + + def test_get_street_deals(self): + """Test getting street-level deals.""" + result = get_street_deals(self.TEST_POLYGON_ID, limit=100, deal_type=2) + data = json.loads(result) + + assert "total_deals" in data + assert isinstance(data["total_deals"], int) + assert data["total_deals"] > 0 # Known polygon should have deals + + assert "deals" in data + assert len(data["deals"]) > 0 + + # Check deal structure + deal = data["deals"][0] + assert "deal_amount" in deal + assert "deal_date" in deal + + def test_get_neighborhood_deals(self): + """Test getting neighborhood-level deals.""" + result = get_neighborhood_deals(self.TEST_POLYGON_ID, limit=100, deal_type=2) + data = json.loads(result) + + assert "total_deals" in data + assert isinstance(data["total_deals"], int) + assert data["total_deals"] > 0 # Known polygon should have deals + + assert "deals" in data + assert len(data["deals"]) > 0 + + def test_get_deals_by_radius(self): + """Test getting polygon metadata by radius.""" + result = get_deals_by_radius(self.TEST_LAT, self.TEST_LON, radius_meters=500) + data = json.loads(result) + + assert "total_polygons" in data + assert isinstance(data["total_polygons"], int) + assert data["total_polygons"] > 0 # Known coords should have polygons + + assert "polygons" in data + assert len(data["polygons"]) > 0 + + # Check polygon metadata structure + polygon = data["polygons"][0] + assert "polygon_id" in polygon or "objectid" in polygon + + def test_get_deals_by_radius_no_results(self): + """Test get_deals_by_radius with coords that have no data.""" + # Use coordinates unlikely to have data + result = get_deals_by_radius(37.66, 38.71, radius_meters=10) + + # Should return a message string, not JSON + assert isinstance(result, str) + assert "No polygons found" in result or "total_polygons" in result diff --git a/tests/fixtures/autocomplete_response.json b/tests/fixtures/autocomplete_response.json new file mode 100644 index 0000000..800752d --- /dev/null +++ b/tests/fixtures/autocomplete_response.json @@ -0,0 +1,35 @@ +[ + { + "text": "סוקולוב חולון", + "id": "street|STREET_MID_POINT|40087", + "type": "street", + "score": 1658.0989, + "coordinates": { + "longitude": 3871467.9873431968, + "latitude": 3766276.9742487124 + }, + "shape": "POINT(3871467.9873431968 3766276.9742487124)" + }, + { + "text": "קופת חולים - לאומית סוקולוב חולון", + "id": "institutes|POI_BLDG|71871", + "type": "institutes", + "score": 1095.5675, + "coordinates": { + "longitude": 3871143.6159368134, + "latitude": 3766329.3192062005 + }, + "shape": "POINT(3871143.6159368134 3766329.3192062005)" + }, + { + "text": "קופת חולים - לאומית סוקולוב חולון", + "id": "poi|POI_MID_POINT|102233", + "type": "poi", + "score": 1099.5448, + "coordinates": { + "longitude": 3871143.6159681133, + "latitude": 3766329.319199102 + }, + "shape": "POINT(3871143.6159681133 3766329.319199102)" + } +] \ No newline at end of file diff --git a/tests/fixtures/neighborhood_deals.json b/tests/fixtures/neighborhood_deals.json new file mode 100644 index 0000000..9d29ce9 --- /dev/null +++ b/tests/fixtures/neighborhood_deals.json @@ -0,0 +1,332 @@ +[ + { + "objectid": 1969420, + "deal_amount": 1600000.0, + "deal_date": "2025-09-11", + "asset_area": 61.0, + "settlement_name_heb": "חולון", + "property_type_description": "דירה", + "neighborhood": "קרית עבודה", + "street_name": null, + "house_number": null, + "floor": null, + "floor_number": null, + "rooms": null, + "priority": null, + "shape": "MULTIPOLYGON(((3870720.4640780594 3766261.471274853,3870704.822454856 3766280.494942658,3870715.8743646624 3766289.5618421077,3870731.3745853226 3766270.5375762666,3870720.4640780594 3766261.471274853)))", + "source_polygon_id": null, + "sourceorder": null, + "settlementId": 6600, + "settlementNameEng": "Holon", + "streetCode": 66000721, + "streetNameHeb": "בילינסון", + "streetNameEng": "Belinson", + "houseNum": 6, + "floorNo": "רביעית", + "dealId": 3633677206, + "dealNatureDescription": "דירה בבית קומות", + "assetRoomNum": 3, + "gushNum": 7172, + "parcelNum": 235, + "subParcelNum": 11, + "polygonId": "52995209", + "price_per_sqm": 26229.51 + }, + { + "objectid": 1968034, + "deal_amount": 1800000.0, + "deal_date": "2025-08-21", + "asset_area": 51.0, + "settlement_name_heb": "חולון", + "property_type_description": "דירה", + "neighborhood": "קרית עבודה", + "street_name": null, + "house_number": null, + "floor": null, + "floor_number": null, + "rooms": null, + "priority": null, + "shape": "MULTIPOLYGON(((3871546.267254118 3766046.2332329005,3871519.7450213963 3766048.8632871644,3871520.755660357 3766058.564386736,3871529.006896269 3766057.780093892,3871529.5742626246 3766063.3353820755,3871547.727459817 3766061.4891484776,3871546.267254118 3766046.2332329005)))", + "source_polygon_id": null, + "sourceorder": null, + "settlementId": 6600, + "settlementNameEng": "Holon", + "streetCode": 66000415, + "streetNameHeb": "חנקין", + "streetNameEng": "Hankin", + "houseNum": 62, + "floorNo": "שניה", + "dealId": 3632991008, + "dealNatureDescription": "דירה בבית קומות", + "assetRoomNum": 3, + "gushNum": 7170, + "parcelNum": 91, + "subParcelNum": 8, + "polygonId": "52487416", + "price_per_sqm": 35294.12 + }, + { + "objectid": 1972678, + "deal_amount": 2170000.0, + "deal_date": "2025-08-20", + "asset_area": 90.0, + "settlement_name_heb": "חולון", + "property_type_description": "דירה", + "neighborhood": "קרית עבודה", + "street_name": null, + "house_number": null, + "floor": null, + "floor_number": null, + "rooms": null, + "priority": null, + "shape": "MULTIPOLYGON(((3870461.085939886 3765412.661079392,3870461.8556948095 3765419.4469509623,3870454.5119660264 3765416.0582189877,3870449.8311605644 3765416.7315218183,3870448.8832249534 3765412.6281236825,3870444.1110105794 3765413.266600847,3870448.3403304657 3765432.7404882894,3870508.9368100218 3765424.106078474,3870506.5193002103 3765406.631910944,3870482.4682015995 3765409.8236224824,3870476.137660146 3765410.663707863,3870461.085939886 3765412.661079392)))", + "source_polygon_id": null, + "sourceorder": null, + "settlementId": 6600, + "settlementNameEng": "Holon", + "streetCode": 66000720, + "streetNameHeb": "לאון בלום", + "streetNameEng": "L?on Blum", + "houseNum": 64, + "floorNo": "שניה", + "dealId": 3633156134, + "dealNatureDescription": "דירה בבית קומות", + "assetRoomNum": 4, + "gushNum": 7176, + "parcelNum": 9, + "subParcelNum": 59, + "polygonId": "52202717", + "price_per_sqm": 24111.11 + }, + { + "objectid": 1967412, + "deal_amount": 2050000.0, + "deal_date": "2025-08-14", + "asset_area": 95.0, + "settlement_name_heb": "חולון", + "property_type_description": "דירה", + "neighborhood": "קרית עבודה", + "street_name": null, + "house_number": null, + "floor": null, + "floor_number": null, + "rooms": null, + "priority": null, + "shape": "MULTIPOLYGON(((3871347.8555616946 3766162.0722470563,3871334.99564493 3766163.205144352,3871336.9037298146 3766187.3432294633,3871349.869159977 3766186.35283101,3871347.8555616946 3766162.0722470563)))", + "source_polygon_id": null, + "sourceorder": null, + "settlementId": 6600, + "settlementNameEng": "Holon", + "streetCode": 66000443, + "streetNameHeb": "שפרינצק", + "streetNameEng": "Sprinzak", + "houseNum": 29, + "floorNo": "שניה", + "dealId": 3632926686, + "dealNatureDescription": "דירה בבית קומות", + "assetRoomNum": 4, + "gushNum": 7170, + "parcelNum": 28, + "subParcelNum": 6, + "polygonId": "53339618", + "price_per_sqm": 21578.95 + }, + { + "objectid": 1972438, + "deal_amount": 2057000.0, + "deal_date": "2025-08-13", + "asset_area": 70.0, + "settlement_name_heb": "חולון", + "property_type_description": "דירה", + "neighborhood": "קרית עבודה", + "street_name": null, + "house_number": null, + "floor": null, + "floor_number": null, + "rooms": null, + "priority": null, + "shape": "MULTIPOLYGON(((3870628.707375865 3765928.3916899217,3870616.197248105 3765933.4442600156,3870624.479632951 3765954.33986368,3870628.9524073373 3765952.58190368,3870629.4304386983 3765953.8507194677,3870634.044605639 3765952.0933281765,3870632.98273369 3765949.4960702136,3870635.237077585 3765948.5461212373,3870632.1913898066 3765941.121957529,3870633.501643529 3765940.5352417007,3870628.707375865 3765928.3916899217)))", + "source_polygon_id": null, + "sourceorder": null, + "settlementId": 6600, + "settlementNameEng": "Holon", + "streetCode": 66000720, + "streetNameHeb": "לאון בלום", + "streetNameEng": "L?on Blum", + "houseNum": 23, + "floorNo": null, + "dealId": 3633759218, + "dealNatureDescription": "דירה בבית קומות", + "assetRoomNum": 3, + "gushNum": 7176, + "parcelNum": 23, + "subParcelNum": 2, + "polygonId": "52475840", + "price_per_sqm": 29385.71 + }, + { + "objectid": 1972437, + "deal_amount": 2207000.0, + "deal_date": "2025-08-13", + "asset_area": 78.0, + "settlement_name_heb": "חולון", + "property_type_description": "דירה", + "neighborhood": "קרית עבודה", + "street_name": null, + "house_number": null, + "floor": null, + "floor_number": null, + "rooms": null, + "priority": null, + "shape": "MULTIPOLYGON(((3870628.707375865 3765928.3916899217,3870616.197248105 3765933.4442600156,3870624.479632951 3765954.33986368,3870628.9524073373 3765952.58190368,3870629.4304386983 3765953.8507194677,3870634.044605639 3765952.0933281765,3870632.98273369 3765949.4960702136,3870635.237077585 3765948.5461212373,3870632.1913898066 3765941.121957529,3870633.501643529 3765940.5352417007,3870628.707375865 3765928.3916899217)))", + "source_polygon_id": null, + "sourceorder": null, + "settlementId": 6600, + "settlementNameEng": "Holon", + "streetCode": 66000720, + "streetNameHeb": "לאון בלום", + "streetNameEng": "L?on Blum", + "houseNum": 23, + "floorNo": null, + "dealId": 3633751330, + "dealNatureDescription": "דירה בבית קומות", + "assetRoomNum": 4, + "gushNum": 7176, + "parcelNum": 23, + "subParcelNum": 2, + "polygonId": "52475840", + "price_per_sqm": 28294.87 + }, + { + "objectid": 1967707, + "deal_amount": 1400000.0, + "deal_date": "2025-08-12", + "asset_area": 74.0, + "settlement_name_heb": "חולון", + "property_type_description": "דירה", + "neighborhood": "קרית עבודה", + "street_name": null, + "house_number": null, + "floor": null, + "floor_number": null, + "rooms": null, + "priority": null, + "shape": "MULTIPOLYGON(((3871406.5046315272 3766032.4932356784,3871393.876109203 3766033.069669732,3871395.3254547855 3766060.893880203,3871408.6494925357 3766060.5448129214,3871406.5046315272 3766032.4932356784)))", + "source_polygon_id": null, + "sourceorder": null, + "settlementId": 6600, + "settlementNameEng": "Holon", + "streetCode": 66000503, + "streetNameHeb": "ביאליק", + "streetNameEng": "Bialik", + "houseNum": 8, + "floorNo": "שניה", + "dealId": 3633004406, + "dealNatureDescription": "דירה בבית קומות", + "assetRoomNum": 3, + "gushNum": 7170, + "parcelNum": 57, + "subParcelNum": 6, + "polygonId": "64907781", + "price_per_sqm": 18918.92 + }, + { + "objectid": 1971495, + "deal_amount": 1000000.0, + "deal_date": "2025-08-07", + "asset_area": 82.0, + "settlement_name_heb": "חולון", + "property_type_description": "דירה", + "neighborhood": "קרית עבודה", + "street_name": null, + "house_number": null, + "floor": null, + "floor_number": null, + "rooms": null, + "priority": null, + "shape": "MULTIPOLYGON(((3871176.244711883 3765574.7175676874,3871108.214092787 3765583.100809277,3871109.35308285 3765593.027038448,3871177.5128026525 3765584.7863754295,3871176.244711883 3765574.7175676874)))", + "source_polygon_id": null, + "sourceorder": null, + "settlementId": 6600, + "settlementNameEng": "HOLON", + "streetCode": 66000304, + "streetNameHeb": "ההסתדרות", + "streetNameEng": "HaHistadrut", + "houseNum": 60, + "floorNo": "ראשונה", + "dealId": 3632654898, + "dealNatureDescription": "דירה בבית קומות", + "assetRoomNum": 4, + "gushNum": 7174, + "parcelNum": 199, + "subParcelNum": 27, + "polygonId": "52506738", + "price_per_sqm": 12195.12 + }, + { + "objectid": 1969515, + "deal_amount": 3000000.0, + "deal_date": "2025-08-06", + "asset_area": 117.0, + "settlement_name_heb": "חולון", + "property_type_description": "דירה", + "neighborhood": "קרית עבודה", + "street_name": null, + "house_number": null, + "floor": null, + "floor_number": null, + "rooms": null, + "priority": null, + "shape": "MULTIPOLYGON(((3870582.7778358534 3766007.818947901,3870555.75388674 3766020.9588606944,3870560.5454790723 3766030.81737579,3870587.5814648867 3766017.6182896625,3870582.7778358534 3766007.818947901)))", + "source_polygon_id": null, + "sourceorder": null, + "settlementId": 6600, + "settlementNameEng": "Holon", + "streetCode": 66000721, + "streetNameHeb": "בילינסון", + "streetNameEng": "Belinson", + "houseNum": 35, + "floorNo": "מרתף+", + "dealId": 3632769506, + "dealNatureDescription": "דירה בבית קומות", + "assetRoomNum": 5, + "gushNum": 7172, + "parcelNum": 55, + "subParcelNum": 2, + "polygonId": "52469890", + "price_per_sqm": 25641.03 + }, + { + "objectid": 1967553, + "deal_amount": 3540000.0, + "deal_date": "2025-08-04", + "asset_area": 130.0, + "settlement_name_heb": "חולון", + "property_type_description": "דירה", + "neighborhood": "קרית עבודה", + "street_name": null, + "house_number": null, + "floor": null, + "floor_number": null, + "rooms": null, + "priority": null, + "shape": "MULTIPOLYGON(((3871283.98382443 3766041.272967665,3871272.3244377715 3766042.777610746,3871274.3714343957 3766058.5450168313,3871278.7452240535 3766057.970421447,3871282.8507001335 3766089.5882179793,3871278.6182943583 3766090.1633791463,3871280.9063065085 3766107.565754848,3871297.410878644 3766105.4883675845,3871296.839837455 3766100.8920923057,3871290.697705125 3766101.684622215,3871289.325197258 3766091.1650558305,3871293.4512298624 3766090.672348892,3871291.964111744 3766079.3353630104,3871299.8747903355 3766078.2657070043,3871298.260388772 3766066.3362180092,3871291.1870977194 3766067.2079217676,3871289.8131462242 3766057.05542465,3871286.0290623982 3766057.490289319,3871283.98382443 3766041.272967665)))", + "source_polygon_id": null, + "sourceorder": null, + "settlementId": 6600, + "settlementNameEng": "Holon", + "streetCode": 66000708, + "streetNameHeb": "שנקר", + "streetNameEng": "Shenkar", + "houseNum": 55, + "floorNo": "שניה", + "dealId": 3632695759, + "dealNatureDescription": "דירה בבית קומות", + "assetRoomNum": 5, + "gushNum": 7170, + "parcelNum": 44, + "subParcelNum": 19, + "polygonId": "52507812", + "price_per_sqm": 27230.77 + } +] \ No newline at end of file diff --git a/tests/fixtures/polygon_metadata.json b/tests/fixtures/polygon_metadata.json new file mode 100644 index 0000000..13b74e8 --- /dev/null +++ b/tests/fixtures/polygon_metadata.json @@ -0,0 +1,82 @@ +[ + { + "dealscount": "10", + "settlementNameHeb": "חולון", + "streetNameHeb": null, + "houseNum": null, + "polygon_id": "7170-31", + "objectid": 14248 + }, + { + "dealscount": "6", + "settlementNameHeb": "חולון", + "streetNameHeb": null, + "houseNum": null, + "polygon_id": "7172-19", + "objectid": 14249 + }, + { + "dealscount": "1", + "settlementNameHeb": "חולון", + "streetNameHeb": null, + "houseNum": null, + "polygon_id": "7172-3", + "objectid": 14301 + }, + { + "dealscount": "14", + "settlementNameHeb": "חולון", + "streetNameHeb": null, + "houseNum": null, + "polygon_id": "7172-212", + "objectid": 14302 + }, + { + "dealscount": "14", + "settlementNameHeb": "חולון", + "streetNameHeb": null, + "houseNum": null, + "polygon_id": "7172-213", + "objectid": 14303 + }, + { + "dealscount": "57", + "settlementNameHeb": "חולון", + "streetNameHeb": null, + "houseNum": null, + "polygon_id": "7172-128", + "objectid": 14304 + }, + { + "dealscount": "1", + "settlementNameHeb": "חולון", + "streetNameHeb": null, + "houseNum": null, + "polygon_id": "7172-5", + "objectid": 14305 + }, + { + "dealscount": "3", + "settlementNameHeb": "חולון", + "streetNameHeb": null, + "houseNum": null, + "polygon_id": "7172-216", + "objectid": 14306 + }, + { + "dealscount": "11", + "settlementNameHeb": "חולון", + "streetNameHeb": null, + "houseNum": null, + "polygon_id": "7172-218", + "objectid": 14314 + }, + { + "dealscount": "1", + "settlementNameHeb": "חולון", + "streetNameHeb": null, + "houseNum": null, + "polygon_id": "7172-7", + "objectid": 14315 + } +] \ No newline at end of file diff --git a/tests/fixtures/street_deals.json b/tests/fixtures/street_deals.json new file mode 100644 index 0000000..4eb2585 --- /dev/null +++ b/tests/fixtures/street_deals.json @@ -0,0 +1,332 @@ +[ + { + "objectid": 1960644, + "deal_amount": 200000.0, + "deal_date": "2024-10-13", + "asset_area": 20.0, + "settlement_name_heb": "חולון", + "property_type_description": "בנין", + "neighborhood": "קרית עבודה", + "street_name": null, + "house_number": null, + "floor": null, + "floor_number": null, + "rooms": null, + "priority": null, + "shape": "MULTIPOLYGON(((3870903.639498367 3766273.3642689525,3870887.6103521264 3766277.301987773,3870893.7433254845 3766302.5584789426,3870909.784521754 3766298.561590301,3870903.639498367 3766273.3642689525)))", + "source_polygon_id": null, + "sourceorder": 1, + "settlementId": 6600, + "settlementNameEng": "Holon", + "streetCode": 66000401, + "streetNameHeb": "סוקולוב", + "streetNameEng": "Sokolov", + "houseNum": 32, + "floorNo": "קרקע", + "dealId": 3605441588, + "dealNatureDescription": "חנות", + "assetRoomNum": null, + "gushNum": 7166, + "parcelNum": 141, + "subParcelNum": 5, + "polygonId": "52385050", + "price_per_sqm": 10000.0 + }, + { + "objectid": 1960654, + "deal_amount": 892000.0, + "deal_date": "2024-08-08", + "asset_area": 69.0, + "settlement_name_heb": "חולון", + "property_type_description": "דירה", + "neighborhood": "קרית עבודה", + "street_name": null, + "house_number": null, + "floor": null, + "floor_number": null, + "rooms": null, + "priority": null, + "shape": "MULTIPOLYGON(((3870903.639498367 3766273.3642689525,3870887.6103521264 3766277.301987773,3870893.7433254845 3766302.5584789426,3870909.784521754 3766298.561590301,3870903.639498367 3766273.3642689525)))", + "source_polygon_id": null, + "sourceorder": 1, + "settlementId": 6600, + "settlementNameEng": "Holon", + "streetCode": 66000401, + "streetNameHeb": "סוקולוב", + "streetNameEng": "Sokolov", + "houseNum": 34, + "floorNo": "שניה", + "dealId": 3603340277, + "dealNatureDescription": "דירה בבית קומות", + "assetRoomNum": 3, + "gushNum": 7166, + "parcelNum": 142, + "subParcelNum": 8, + "polygonId": "52385050", + "price_per_sqm": 12927.54 + }, + { + "objectid": 1960646, + "deal_amount": 1650000.0, + "deal_date": "2022-08-17", + "asset_area": 59.0, + "settlement_name_heb": "חולון", + "property_type_description": "דירה", + "neighborhood": "קרית עבודה", + "street_name": null, + "house_number": null, + "floor": null, + "floor_number": null, + "rooms": null, + "priority": null, + "shape": "MULTIPOLYGON(((3870903.639498367 3766273.3642689525,3870887.6103521264 3766277.301987773,3870893.7433254845 3766302.5584789426,3870909.784521754 3766298.561590301,3870903.639498367 3766273.3642689525)))", + "source_polygon_id": null, + "sourceorder": 1, + "settlementId": 6600, + "settlementNameEng": "Holon", + "streetCode": 66000401, + "streetNameHeb": "סוקולוב", + "streetNameEng": "Sokolov", + "houseNum": 32, + "floorNo": "ראשונה", + "dealId": 3547500052, + "dealNatureDescription": "דירה בבית קומות", + "assetRoomNum": 2, + "gushNum": 7166, + "parcelNum": 141, + "subParcelNum": 7, + "polygonId": "52385050", + "price_per_sqm": 27966.1 + }, + { + "objectid": 1960650, + "deal_amount": 500000.0, + "deal_date": "2019-03-03", + "asset_area": 29.0, + "settlement_name_heb": "חולון", + "property_type_description": "בנין", + "neighborhood": "קרית עבודה", + "street_name": null, + "house_number": null, + "floor": null, + "floor_number": null, + "rooms": null, + "priority": null, + "shape": "MULTIPOLYGON(((3870903.639498367 3766273.3642689525,3870887.6103521264 3766277.301987773,3870893.7433254845 3766302.5584789426,3870909.784521754 3766298.561590301,3870903.639498367 3766273.3642689525)))", + "source_polygon_id": null, + "sourceorder": 1, + "settlementId": 6600, + "settlementNameEng": "Holon", + "streetCode": 66000401, + "streetNameHeb": "סוקולוב", + "streetNameEng": "Sokolov", + "houseNum": 34, + "floorNo": "קרקע", + "dealId": 3453745430, + "dealNatureDescription": "חנות", + "assetRoomNum": null, + "gushNum": 7166, + "parcelNum": 142, + "subParcelNum": 4, + "polygonId": "52385050", + "price_per_sqm": 17241.38 + }, + { + "objectid": 1960640, + "deal_amount": 510000.0, + "deal_date": "2016-12-06", + "asset_area": 98.0, + "settlement_name_heb": "חולון", + "property_type_description": "בנין", + "neighborhood": "קרית עבודה", + "street_name": null, + "house_number": null, + "floor": null, + "floor_number": null, + "rooms": null, + "priority": null, + "shape": "MULTIPOLYGON(((3870903.639498367 3766273.3642689525,3870887.6103521264 3766277.301987773,3870893.7433254845 3766302.5584789426,3870909.784521754 3766298.561590301,3870903.639498367 3766273.3642689525)))", + "source_polygon_id": null, + "sourceorder": 1, + "settlementId": 6600, + "settlementNameEng": "Holon", + "streetCode": 66000401, + "streetNameHeb": "סוקולוב", + "streetNameEng": "Sokolov", + "houseNum": 32, + "floorNo": "מרתף", + "dealId": 3375045810, + "dealNatureDescription": "מחסנים", + "assetRoomNum": null, + "gushNum": 7166, + "parcelNum": 141, + "subParcelNum": 1, + "polygonId": "52385050", + "price_per_sqm": 5204.08 + }, + { + "objectid": 1960642, + "deal_amount": 899000.0, + "deal_date": "2013-12-30", + "asset_area": 42.0, + "settlement_name_heb": "חולון", + "property_type_description": "דירה", + "neighborhood": "קרית עבודה", + "street_name": null, + "house_number": null, + "floor": null, + "floor_number": null, + "rooms": null, + "priority": null, + "shape": "MULTIPOLYGON(((3870903.639498367 3766273.3642689525,3870887.6103521264 3766277.301987773,3870893.7433254845 3766302.5584789426,3870909.784521754 3766298.561590301,3870903.639498367 3766273.3642689525)))", + "source_polygon_id": null, + "sourceorder": 1, + "settlementId": 6600, + "settlementNameEng": "Holon", + "streetCode": 66000401, + "streetNameHeb": "סוקולוב", + "streetNameEng": "Sokolov", + "houseNum": 32, + "floorNo": "שלישית", + "dealId": 3306035061, + "dealNatureDescription": "דירה בבית קומות", + "assetRoomNum": 2, + "gushNum": 7166, + "parcelNum": 141, + "subParcelNum": 11, + "polygonId": "52385050", + "price_per_sqm": 21404.76 + }, + { + "objectid": 1960638, + "deal_amount": 580000.0, + "deal_date": "2013-03-11", + "asset_area": 47.0, + "settlement_name_heb": "חולון", + "property_type_description": "בנין", + "neighborhood": "קרית עבודה", + "street_name": null, + "house_number": null, + "floor": null, + "floor_number": null, + "rooms": null, + "priority": null, + "shape": "MULTIPOLYGON(((3870903.639498367 3766273.3642689525,3870887.6103521264 3766277.301987773,3870893.7433254845 3766302.5584789426,3870909.784521754 3766298.561590301,3870903.639498367 3766273.3642689525)))", + "source_polygon_id": null, + "sourceorder": 1, + "settlementId": 6600, + "settlementNameEng": "Holon", + "streetCode": 66000401, + "streetNameHeb": "סוקולוב", + "streetNameEng": "Sokolov", + "houseNum": 32, + "floorNo": "מרתף", + "dealId": 3280438878, + "dealNatureDescription": "תעשיה", + "assetRoomNum": null, + "gushNum": 7166, + "parcelNum": 141, + "subParcelNum": 1, + "polygonId": "52385050", + "price_per_sqm": 12340.43 + }, + { + "objectid": 1960649, + "deal_amount": 720000.0, + "deal_date": "2008-11-20", + "asset_area": 20.0, + "settlement_name_heb": "חולון", + "property_type_description": "בנין", + "neighborhood": "קרית עבודה", + "street_name": null, + "house_number": null, + "floor": null, + "floor_number": null, + "rooms": null, + "priority": null, + "shape": "MULTIPOLYGON(((3870903.639498367 3766273.3642689525,3870887.6103521264 3766277.301987773,3870893.7433254845 3766302.5584789426,3870909.784521754 3766298.561590301,3870903.639498367 3766273.3642689525)))", + "source_polygon_id": null, + "sourceorder": 1, + "settlementId": 6600, + "settlementNameEng": "Holon", + "streetCode": 66000401, + "streetNameHeb": "סוקולוב", + "streetNameEng": "Sokolov", + "houseNum": 34, + "floorNo": "קרקע", + "dealId": 3140497058, + "dealNatureDescription": "חנות", + "assetRoomNum": null, + "gushNum": 7166, + "parcelNum": 142, + "subParcelNum": 3, + "polygonId": "52385050", + "price_per_sqm": 36000.0 + }, + { + "objectid": 1960643, + "deal_amount": 100000.0, + "deal_date": "2008-11-09", + "asset_area": 58.0, + "settlement_name_heb": "חולון", + "property_type_description": "דירה", + "neighborhood": "קרית עבודה", + "street_name": null, + "house_number": null, + "floor": null, + "floor_number": null, + "rooms": null, + "priority": null, + "shape": "MULTIPOLYGON(((3870903.639498367 3766273.3642689525,3870887.6103521264 3766277.301987773,3870893.7433254845 3766302.5584789426,3870909.784521754 3766298.561590301,3870903.639498367 3766273.3642689525)))", + "source_polygon_id": null, + "sourceorder": 1, + "settlementId": 6600, + "settlementNameEng": "Holon", + "streetCode": 66000401, + "streetNameHeb": "סוקולוב", + "streetNameEng": "Sokolov", + "houseNum": 32, + "floorNo": "שלישית", + "dealId": 3139951568, + "dealNatureDescription": "דירה בבית קומות", + "assetRoomNum": 3, + "gushNum": 7166, + "parcelNum": 141, + "subParcelNum": 12, + "polygonId": "52385050", + "price_per_sqm": 1724.14 + }, + { + "objectid": 1960647, + "deal_amount": 430000.0, + "deal_date": "2008-06-17", + "asset_area": 42.0, + "settlement_name_heb": "חולון", + "property_type_description": "דירה", + "neighborhood": "קרית עבודה", + "street_name": null, + "house_number": null, + "floor": null, + "floor_number": null, + "rooms": null, + "priority": null, + "shape": "MULTIPOLYGON(((3870903.639498367 3766273.3642689525,3870887.6103521264 3766277.301987773,3870893.7433254845 3766302.5584789426,3870909.784521754 3766298.561590301,3870903.639498367 3766273.3642689525)))", + "source_polygon_id": null, + "sourceorder": 1, + "settlementId": 6600, + "settlementNameEng": "Holon", + "streetCode": 66000401, + "streetNameHeb": "סוקולוב", + "streetNameEng": "Sokolov", + "houseNum": 32, + "floorNo": "שניה", + "dealId": 3137311234, + "dealNatureDescription": "דירה בבית קומות", + "assetRoomNum": 2, + "gushNum": 7166, + "parcelNum": 141, + "subParcelNum": 9, + "polygonId": "52385050", + "price_per_sqm": 10238.1 + } +] \ No newline at end of file diff --git a/tests/test_mcp_tools_fast.py b/tests/test_mcp_tools_fast.py new file mode 100644 index 0000000..a1e5544 --- /dev/null +++ b/tests/test_mcp_tools_fast.py @@ -0,0 +1,155 @@ +""" +Fast unit tests for MCP tools using cached fixtures. + +These tests use pre-recorded API responses to run quickly (~1s) +without hitting the real Govmap API. + +For full E2E tests with real API calls, see tests/e2e/test_mcp_tools.py +""" +import json +import pytest +from pathlib import Path +from unittest.mock import patch, Mock +from nadlan_mcp.govmap.models import Deal, AutocompleteResult, AutocompleteResponse, CoordinatePoint +from nadlan_mcp.fastmcp_server import ( + autocomplete_address, + find_recent_deals_for_address, + get_street_deals, + get_neighborhood_deals, + get_deals_by_radius, +) + + +# Load fixtures +FIXTURES_DIR = Path(__file__).parent / "fixtures" + + +def load_fixture(filename): + """Load a JSON fixture file.""" + with open(FIXTURES_DIR / filename, encoding="utf-8") as f: + return json.load(f) + + +@pytest.fixture +def mock_autocomplete_data(): + """Cached autocomplete response.""" + data = load_fixture("autocomplete_response.json") + results = [AutocompleteResult.model_validate(r) for r in data] + return AutocompleteResponse(results=results, resultsCount=len(results)) + + +@pytest.fixture +def mock_street_deals_data(): + """Cached street deals response.""" + data = load_fixture("street_deals.json") + return [Deal.model_validate(d) for d in data] + + +@pytest.fixture +def mock_neighborhood_deals_data(): + """Cached neighborhood deals response.""" + data = load_fixture("neighborhood_deals.json") + return [Deal.model_validate(d) for d in data] + + +@pytest.fixture +def mock_polygon_metadata_data(): + """Cached polygon metadata response.""" + return load_fixture("polygon_metadata.json") + + +class TestMCPToolsFast: + """Fast unit tests for MCP tools using mocked data.""" + + @patch('nadlan_mcp.fastmcp_server.client') + def test_autocomplete_address_fast(self, mock_client, mock_autocomplete_data): + """Test autocomplete with cached data.""" + mock_client.autocomplete_address.return_value = mock_autocomplete_data + + result = autocomplete_address("חולון סוקולוב") + data = json.loads(result) + + assert isinstance(data, list) + assert len(data) > 0 + assert "text" in data[0] + assert "coordinates" in data[0] + + @patch('nadlan_mcp.fastmcp_server.client') + def test_get_street_deals_fast(self, mock_client, mock_street_deals_data): + """Test street deals with cached data.""" + mock_client.get_street_deals.return_value = mock_street_deals_data + + result = get_street_deals("52385050", limit=100, deal_type=2) + data = json.loads(result) + + assert "total_deals" in data + assert data["total_deals"] == len(mock_street_deals_data) + assert "deals" in data + assert len(data["deals"]) > 0 + + # Verify deal structure + deal = data["deals"][0] + assert "deal_amount" in deal + assert "deal_date" in deal + + @patch('nadlan_mcp.fastmcp_server.client') + def test_get_neighborhood_deals_fast(self, mock_client, mock_neighborhood_deals_data): + """Test neighborhood deals with cached data.""" + mock_client.get_neighborhood_deals.return_value = mock_neighborhood_deals_data + + result = get_neighborhood_deals("52385050", limit=100, deal_type=2) + data = json.loads(result) + + assert "total_deals" in data + assert data["total_deals"] == len(mock_neighborhood_deals_data) + assert "deals" in data + + @patch('nadlan_mcp.fastmcp_server.client') + def test_get_deals_by_radius_fast(self, mock_client, mock_polygon_metadata_data): + """Test deals by radius with cached data.""" + mock_client.get_deals_by_radius.return_value = mock_polygon_metadata_data + + result = get_deals_by_radius(37.6629, 38.7093, radius_meters=500) + data = json.loads(result) + + assert "total_polygons" in data + assert data["total_polygons"] == len(mock_polygon_metadata_data) + assert "polygons" in data + + @pytest.mark.skip(reason="Complex workflow with statistics - tested in E2E suite") + @patch('nadlan_mcp.fastmcp_server.client') + def test_find_recent_deals_fast(self, mock_client, mock_street_deals_data, + mock_neighborhood_deals_data, mock_polygon_metadata_data, + mock_autocomplete_data): + """Test find_recent_deals with fully mocked workflow. + + NOTE: This test is skipped because find_recent_deals_for_address has complex + statistics calculations that require specific data structure. Use E2E tests + in tests/e2e/test_mcp_tools.py for full integration testing. + """ + pass + + @patch('nadlan_mcp.fastmcp_server.client') + def test_no_deals_found(self, mock_client): + """Test handling when no deals are found.""" + mock_client.get_street_deals.return_value = [] + + result = get_street_deals("999999", limit=100, deal_type=2) + + # Should return a message string when no deals found + assert isinstance(result, str) + assert "No" in result or "found" in result.lower() + + @patch('nadlan_mcp.fastmcp_server.client') + def test_deal_type_filtering(self, mock_client, mock_street_deals_data): + """Test that deal_type parameter is passed correctly.""" + mock_client.get_street_deals.return_value = mock_street_deals_data + + # Test with deal_type=1 (first hand) + result = get_street_deals("52385050", limit=100, deal_type=1) + data = json.loads(result) + + # Verify client was called with correct deal_type + mock_client.get_street_deals.assert_called_once_with("52385050", 100, deal_type=1) + assert "deal_type" in data + assert data["deal_type"] == 1