initial
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Test fixtures and utilities."""
|
||||
@@ -0,0 +1,236 @@
|
||||
"""Fixture data for testing without hitting live APIs."""
|
||||
# noqa: E501
|
||||
|
||||
SAMPLE_FINN_SEARCH_HTML = """
|
||||
<!DOCTYPE html>
|
||||
<html lang="no">
|
||||
<head><title>FINN.no - Leiligheter til salgs</title></head>
|
||||
<body>
|
||||
<div class="listings">
|
||||
<article class="listing-card" data-id="462400360">
|
||||
<a href="https://www.finn.no/realestate/homes/ad.html?finnkode=462400360" class="listing-link">
|
||||
<div class="title">Flott 3-roms i Ferner</div>
|
||||
<div class="meta">
|
||||
<span class="area">77 m²</span>
|
||||
<span class="price">7 200 991 kr</span>
|
||||
<span class="price-per-sqm">93 500 kr/m²</span>
|
||||
</div>
|
||||
</a>
|
||||
<div class="details">
|
||||
<span class="bedrooms">3</span>
|
||||
<span class="location">Grünerløkka, Oslo</span>
|
||||
<span class="common-costs">3 500 kr/mnd</span>
|
||||
</div>
|
||||
</article>
|
||||
<article class="listing-card" data-id="460784945">
|
||||
<a href="https://www.finn.no/realestate/homes/ad.html?finnkode=460784945" class="listing-link">
|
||||
<div class="title">Leilighet med potensial - må renoveres</div>
|
||||
<div class="meta">
|
||||
<span class="area">65 m²</span>
|
||||
<span class="price">6 500 000 kr</span>
|
||||
<span class="price-per-sqm">100 000 kr/m²</span>
|
||||
</div>
|
||||
</a>
|
||||
<div class="details">
|
||||
<span class="bedrooms">2</span>
|
||||
<span class="location">Sagene, Oslo</span>
|
||||
<span class="common-costs">2 800 kr/mnd</span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
# noqa: E501
|
||||
SAMPLE_FINN_SEARCH_HTML_NEW = """
|
||||
<!DOCTYPE html>
|
||||
<html lang="no">
|
||||
<head><title>FINN.no - Leiligheter til salgs</title></head>
|
||||
<body>
|
||||
<div class="listings">
|
||||
<article class="relative isolate sf-search-ad card card--cardShadow">
|
||||
<div class="col-span-2 p-16 grid sm:grid-cols-2">
|
||||
<h2 class="h4 mb-0 col-span-2 mt-12 sm:mt-24 sf-realestate-heading">IDYLLISKE ILADALEN - Lekker 3-roms loftsleilighet fra 2016 | Privat, solrik takterrasse | Peis | Gulvareal på 77kvm | Sentralt, men rolig</h2>
|
||||
<a href="https://www.finn.no/realestate/homes/ad.html?finnkode=462880791" class="sf-search-ad-link s-text!">IDYLLISKE ILADALEN - Lekker 3-roms loftsleilighet fra 2016 | Privat, solrik takterrasse | Peis | Gulvareal på 77kvm | Sentralt, men rolig</a>
|
||||
<div class="mt-4 sf-line-clamp-2 sm:order-first sm:text-right sm:mt-0 sm:ml-16 sf-realestate-location">Lofotgata 4B, Oslo</div>
|
||||
<div class="col-span-2 mt-16 flex justify-between sm:mt-4 sm:block space-x-12 font-bold">62 m² 6 750 000 kr</div>
|
||||
<div class="col-span-2 sm:flex sm:items-baseline sm:justify-between">Totalpris: 7 253 377 kr ∙ Fellesutg.: 7 067 kr ∙ Andel ∙ Leilighet ∙ 2 soverom</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
SAMPLE_FINN_LISTING_HTML = """
|
||||
<!DOCTYPE html>
|
||||
<html lang="no">
|
||||
<head><title>Flott 3-roms i Ferner - FINN.no</title></head>
|
||||
<body>
|
||||
<div class="listing-details">
|
||||
<div class="heading">
|
||||
<h1>Flott 3-roms i Ferner</h1>
|
||||
<div class="price">Totalpris: 7 200 991 kr</div>
|
||||
</div>
|
||||
<div class="properties">
|
||||
<dl>
|
||||
<dt>Adresse</dt>
|
||||
<dd>Fernerveien 42, 0554 Oslo</dd>
|
||||
<dt>Område</dt>
|
||||
<dd>Grünerløkka</dd>
|
||||
<dt>Postnummer</dt>
|
||||
<dd>0554</dd>
|
||||
<dt>Eierform</dt>
|
||||
<dd>Eierbolig</dd>
|
||||
<dt>Eiendomstype</dt>
|
||||
<dd>Leilighet</dd>
|
||||
<dt>Prisantydning</dt>
|
||||
<dd>7 200 000 kr</dd>
|
||||
<dt>Totalpris</dt>
|
||||
<dd>7 200 991 kr</dd>
|
||||
<dt>Fellesgjeld</dt>
|
||||
<dd>0 kr</dd>
|
||||
<dt>Felles utgifter</dt>
|
||||
<dd>3 500 kr/mnd</dd>
|
||||
<dt>Boligareal</dt>
|
||||
<dd>77 m²</dd>
|
||||
<dt>Rom</dt>
|
||||
<dd>4</dd>
|
||||
<dt>Soverom</dt>
|
||||
<dd>3</dd>
|
||||
<dt>Etasje</dt>
|
||||
<dd>4. etasje</dd>
|
||||
<dt>Byggeår</dt>
|
||||
<dd>2005</dd>
|
||||
<dt>Energimerking</dt>
|
||||
<dd>C</dd>
|
||||
<dt>Oppvarming</dt>
|
||||
<dd>Fjernvarme</dd>
|
||||
<dt>Balkonger/terrasser</dt>
|
||||
<dd>Ja, balkonger</dd>
|
||||
<dt>Heis</dt>
|
||||
<dd>Ja</dd>
|
||||
<dt>Parkering/garasje</dt>
|
||||
<dd>Privat parkering</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="description">
|
||||
<h2>Beskrivelse</h2>
|
||||
<p>Flott beliggenhet med fin utsikt over Oslo. Moderne kjøkken og bad.</p>
|
||||
<p>Klar til visning!</p>
|
||||
</div>
|
||||
<div class="broker">
|
||||
<div class="broker-info">
|
||||
<span class="broker-name">Meglerhuset AS</span>
|
||||
<span class="broker-contact">Telefon: 21 00 00 00</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
SAMPLE_FINN_LISTING_HTML_NEW = """
|
||||
<!DOCTYPE html>
|
||||
<html lang="no">
|
||||
<head><title>Romslig 5-roms i 5.etasje med heisadkomst</title></head>
|
||||
<body>
|
||||
<div data-testid="object-details">
|
||||
<h1>Romslig 5-roms i 5.etasje med heisadkomst | 2 hybler | 4 balkonger | Ingen dokavgift!</h1>
|
||||
<span data-testid="object-address">Hegdehaugsveien 3, 0352 Oslo</span>
|
||||
<span data-testid="local-area-name">Homansbyen</span>
|
||||
<section data-testid="pricing-details">
|
||||
<div data-testid="pricing-incicative-price">Prisantydning10 900 000 kr</div>
|
||||
<div data-testid="pricing-total-price"><dt>Totalpris</dt><dd>10 986 901 kr</dd></div>
|
||||
<div data-testid="pricing-joint-debt"><dt>Fellesgjeld</dt><dd>76 911 kr</dd></div>
|
||||
<div data-testid="pricing-common-monthly-cost"><dt>Felleskost/mnd.</dt><dd>12 011 kr</dd></div>
|
||||
</section>
|
||||
<section data-testid="key-info">
|
||||
<div data-testid="info-property-type">BoligtypeLeilighet</div>
|
||||
<div data-testid="info-ownership-type">EieformAndel</div>
|
||||
<div data-testid="info-bedrooms">Soverom2</div>
|
||||
<div data-testid="info-rooms">Rom5</div>
|
||||
<div data-testid="info-construction-year">Byggeår1938</div>
|
||||
<div data-testid="info-usable-i-area">Internt bruksareal124 m² (BRA-i)</div>
|
||||
</section>
|
||||
<section data-testid="object-facilities">FasiliteterBalkong/TerrasseParkettHeis</section>
|
||||
<section data-testid="om boligen">
|
||||
<h2>Om boligen</h2>
|
||||
<p>Her bor du med kort vei til daglige behov og offentlig transport.</p>
|
||||
</section>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
SAMPLE_EIENDOM_UNIT_JSON = {
|
||||
"units": [
|
||||
{
|
||||
"unitCode": "c-gxw-xmyum-s2a",
|
||||
"address": "Fernerveien 42, 0554 Oslo",
|
||||
"municipality": "Oslo",
|
||||
"lat": 59.9287,
|
||||
"lon": 10.7803,
|
||||
"propertyType": "APARTMENT",
|
||||
"floor": 4,
|
||||
"rooms": 4,
|
||||
"constructionYear": 2005,
|
||||
"usableArea": 77,
|
||||
"estimatedSellingPrice": 7650000,
|
||||
"estimatedSellingPriceLower": 6900000,
|
||||
"estimatedSellingPriceUpper": 8400000,
|
||||
"listingPrice": 7200000,
|
||||
"listingSquareMeterPrice": 93500,
|
||||
"commonCosts": 3500,
|
||||
"daysOnMarket": 12,
|
||||
"saleStatus": "FOR_SALE",
|
||||
"marketPlacementScore": "ABOVE_AVERAGE",
|
||||
"similarUnitCount": 12,
|
||||
"averageSquareMeterPrice": 98000,
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
SAMPLE_EIENDOM_SIMILAR_UNITS_JSON = {
|
||||
"units": [
|
||||
{
|
||||
"unitCode": "c-recent-1",
|
||||
"address": "Birketveien 10, 0554 Oslo",
|
||||
"lat": 59.9290,
|
||||
"lon": 10.7810,
|
||||
"propertyType": "APARTMENT",
|
||||
"floor": 3,
|
||||
"rooms": 3,
|
||||
"constructionYear": 2004,
|
||||
"usableArea": 75,
|
||||
"listingPrice": 7100000,
|
||||
"sellingPrice": 7050000,
|
||||
"sharedDebt": 0,
|
||||
"commonCosts": 3400,
|
||||
"squareMeterPrice": 94000,
|
||||
"daysOnMarket": 18,
|
||||
"saleStatus": "SOLD",
|
||||
"finalizedAt": "2024-05-01",
|
||||
},
|
||||
{
|
||||
"unitCode": "c-recent-2",
|
||||
"address": "Sommers gate 5, 0554 Oslo",
|
||||
"lat": 59.9280,
|
||||
"lon": 10.7820,
|
||||
"propertyType": "APARTMENT",
|
||||
"floor": 2,
|
||||
"rooms": 4,
|
||||
"constructionYear": 2006,
|
||||
"usableArea": 80,
|
||||
"listingPrice": 7400000,
|
||||
"sellingPrice": 7350000,
|
||||
"sharedDebt": 0,
|
||||
"commonCosts": 3600,
|
||||
"squareMeterPrice": 91875,
|
||||
"daysOnMarket": 22,
|
||||
"saleStatus": "SOLD",
|
||||
"finalizedAt": "2024-04-28",
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
from finn_eiendom.ad import scrape_ad
|
||||
from tests.fixtures import SAMPLE_FINN_LISTING_HTML, SAMPLE_FINN_LISTING_HTML_NEW
|
||||
|
||||
|
||||
def test_scrape_ad():
|
||||
ad = scrape_ad(
|
||||
SAMPLE_FINN_LISTING_HTML,
|
||||
url="https://www.finn.no/realestate/homes/ad.html?finnkode=462400360",
|
||||
)
|
||||
assert ad.finnkode == "462400360"
|
||||
assert ad.title == "Flott 3-roms i Ferner"
|
||||
assert ad.address == "Fernerveien 42, 0554 Oslo"
|
||||
assert ad.area_m2 == 77
|
||||
assert ad.asking_price == 7200000
|
||||
assert ad.total_price == 7200991
|
||||
assert ad.common_costs == 3500
|
||||
assert ad.rooms == 4
|
||||
assert ad.bedrooms == 3
|
||||
assert ad.floor == "4. etasje"
|
||||
assert ad.construction_year == 2005
|
||||
assert ad.energy_rating == "C"
|
||||
assert ad.heating == "Fjernvarme"
|
||||
assert "Moderne kjøkken" in ad.listing_description
|
||||
assert ad.broker_company == "Meglerhuset AS"
|
||||
|
||||
|
||||
def test_scrape_ad_new_structure():
|
||||
ad = scrape_ad(
|
||||
SAMPLE_FINN_LISTING_HTML_NEW,
|
||||
url="https://www.finn.no/realestate/homes/ad.html?finnkode=455978973",
|
||||
)
|
||||
assert ad.finnkode == "455978973"
|
||||
assert ad.title.startswith("Romslig 5-roms i 5.etasje")
|
||||
assert ad.address == "Hegdehaugsveien 3, 0352 Oslo"
|
||||
assert ad.property_type == "Leilighet"
|
||||
assert ad.ownership_type == "Andel"
|
||||
assert ad.asking_price == 10900000
|
||||
assert ad.total_price == 10986901
|
||||
assert ad.common_costs == 12011
|
||||
assert ad.area_m2 == 124
|
||||
assert ad.rooms == 5
|
||||
assert ad.bedrooms == 2
|
||||
assert ad.construction_year == 1938
|
||||
assert ad.floor == "5. etasje"
|
||||
assert "kort vei" in ad.listing_description.lower()
|
||||
@@ -0,0 +1,71 @@
|
||||
import tempfile
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from finn_eiendom.cache import (
|
||||
get_eiendom_unit,
|
||||
get_finn_ad,
|
||||
get_search_page,
|
||||
get_similar_units,
|
||||
init_db,
|
||||
save_eiendom_unit,
|
||||
save_finn_ad,
|
||||
save_search_page,
|
||||
save_similar_units,
|
||||
)
|
||||
from finn_eiendom.models import EiendomUnit, FinnAd, SimilarUnit
|
||||
|
||||
|
||||
def test_cache_roundtrip():
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
db_path = Path(tmpdir) / "cache.sqlite"
|
||||
conn = init_db(str(db_path))
|
||||
|
||||
ad = FinnAd(finnkode="1234", url="https://example.com", title="Test")
|
||||
save_finn_ad(conn, ad)
|
||||
loaded_ad = get_finn_ad(conn, "1234")
|
||||
assert loaded_ad is not None
|
||||
assert loaded_ad.finnkode == "1234"
|
||||
assert loaded_ad.url == "https://example.com"
|
||||
|
||||
unit = EiendomUnit(unit_code="abc", address="Oslo")
|
||||
save_eiendom_unit(conn, unit)
|
||||
loaded_unit = get_eiendom_unit(conn, "abc")
|
||||
assert loaded_unit is not None
|
||||
assert loaded_unit.address == "Oslo"
|
||||
|
||||
comps = [
|
||||
SimilarUnit(unit_code="x1"),
|
||||
SimilarUnit(unit_code="x2"),
|
||||
]
|
||||
save_similar_units(conn, "abc", "RECENTLY_SOLD", comps)
|
||||
loaded_comps = get_similar_units(conn, "abc", "RECENTLY_SOLD")
|
||||
assert len(loaded_comps) == 2
|
||||
assert loaded_comps[0].unit_code == "x1"
|
||||
|
||||
|
||||
def test_search_page_cache_roundtrip():
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
conn = init_db(str(Path(tmpdir) / "cache.sqlite"))
|
||||
|
||||
html = "<html><body>search page</body></html>"
|
||||
url = "https://www.finn.no/realestate/homes/search.html"
|
||||
|
||||
save_search_page(conn, url, html, ttl_minutes=5)
|
||||
loaded_html = get_search_page(conn, url)
|
||||
assert loaded_html == html
|
||||
|
||||
|
||||
def test_finn_ad_cache_ttl_expiration():
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
conn = init_db(str(Path(tmpdir) / "cache.sqlite"))
|
||||
|
||||
ad = FinnAd(
|
||||
finnkode="1234",
|
||||
url="https://example.com",
|
||||
title="Test",
|
||||
detail_fetched_at=datetime.now(UTC) - timedelta(hours=2),
|
||||
)
|
||||
save_finn_ad(conn, ad)
|
||||
expired_ad = get_finn_ad(conn, "1234", ttl_hours=1)
|
||||
assert expired_ad is None
|
||||
@@ -0,0 +1,44 @@
|
||||
from finn_eiendom.eiendom_no import (
|
||||
build_unit_vector,
|
||||
decode_unit_vector,
|
||||
parse_eiendom_unit_json,
|
||||
parse_similar_units_json,
|
||||
resolve_unit_from_finn_url,
|
||||
)
|
||||
from tests.fixtures import (
|
||||
SAMPLE_EIENDOM_SIMILAR_UNITS_JSON,
|
||||
SAMPLE_EIENDOM_UNIT_JSON,
|
||||
)
|
||||
|
||||
|
||||
def test_parse_eiendom_unit_json():
|
||||
unit = parse_eiendom_unit_json(SAMPLE_EIENDOM_UNIT_JSON["units"][0])
|
||||
assert unit.unit_code == "c-gxw-xmyum-s2a"
|
||||
assert unit.address == "Fernerveien 42, 0554 Oslo"
|
||||
assert unit.estimated_selling_price == 7650000
|
||||
assert unit.listing_sqm_price == 93500
|
||||
|
||||
|
||||
def test_unit_vector_roundtrip():
|
||||
unit = parse_eiendom_unit_json(SAMPLE_EIENDOM_UNIT_JSON["units"][0])
|
||||
vector = build_unit_vector(unit)
|
||||
decoded = decode_unit_vector(vector)
|
||||
assert decoded["ptype"] == "APARTMENT"
|
||||
assert decoded["area"] == 77
|
||||
assert decoded["price"] == 7200000
|
||||
assert isinstance(decoded, dict)
|
||||
assert decoded["lon"] == unit.lng
|
||||
|
||||
|
||||
def test_parse_similar_units_json():
|
||||
comps = parse_similar_units_json(SAMPLE_EIENDOM_SIMILAR_UNITS_JSON)
|
||||
assert len(comps) == 2
|
||||
assert comps[0].unit_code == "c-recent-1"
|
||||
assert comps[1].selling_price == 7350000
|
||||
|
||||
|
||||
def test_resolve_unit_from_finn_url():
|
||||
unit_code = resolve_unit_from_finn_url(
|
||||
"https://www.finn.no/realestate/homes/ad.html?finnkode=462400360"
|
||||
)
|
||||
assert unit_code == "462400360"
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Tests for HTTP client retry logic."""
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
from finn_eiendom.http import HTTPClient
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_retries_on_500():
|
||||
"""Test that HTTPClient retries on 500 errors and succeeds on second attempt."""
|
||||
client = HTTPClient(request_delay_seconds=0.0, retries=2)
|
||||
|
||||
with respx.mock:
|
||||
route = respx.get("https://example.com/api")
|
||||
route.side_effect = [
|
||||
httpx.Response(500, text="Server Error"),
|
||||
httpx.Response(200, text="Success"),
|
||||
]
|
||||
|
||||
response = await client.get("https://example.com/api")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_raises_on_404():
|
||||
"""Test that HTTPClient raises on 4xx errors immediately."""
|
||||
client = HTTPClient(request_delay_seconds=0.0, retries=2)
|
||||
|
||||
with respx.mock:
|
||||
respx.get("https://example.com/api").mock(return_value=httpx.Response(404))
|
||||
|
||||
with pytest.raises(httpx.HTTPStatusError) as exc_info:
|
||||
await client.get("https://example.com/api")
|
||||
|
||||
assert exc_info.value.response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_retries_on_502_bad_gateway():
|
||||
"""Test that HTTPClient retries on 502 Bad Gateway."""
|
||||
client = HTTPClient(request_delay_seconds=0.0, retries=2)
|
||||
|
||||
with respx.mock:
|
||||
route = respx.get("https://example.com/api")
|
||||
route.side_effect = [
|
||||
httpx.Response(502, text="Bad Gateway"),
|
||||
httpx.Response(200, text="Success"),
|
||||
]
|
||||
|
||||
response = await client.get("https://example.com/api")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_retries_on_503():
|
||||
"""Test that HTTPClient retries POST on 503 Service Unavailable."""
|
||||
client = HTTPClient(request_delay_seconds=0.0, retries=2)
|
||||
|
||||
with respx.mock:
|
||||
route = respx.post("https://example.com/api")
|
||||
route.side_effect = [
|
||||
httpx.Response(503, text="Service Unavailable"),
|
||||
httpx.Response(201, json={"success": True}),
|
||||
]
|
||||
|
||||
response = await client.post("https://example.com/api", json={"test": "data"})
|
||||
assert response.status_code == 201
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_eventually_fails_on_persistent_500():
|
||||
"""Test that HTTPClient gives up after max retries."""
|
||||
client = HTTPClient(request_delay_seconds=0.0, retries=1)
|
||||
|
||||
with respx.mock:
|
||||
respx.get("https://example.com/api").mock(return_value=httpx.Response(500))
|
||||
|
||||
with pytest.raises(httpx.HTTPStatusError) as exc_info:
|
||||
await client.get("https://example.com/api")
|
||||
|
||||
assert exc_info.value.response.status_code == 500
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Tests for the MCP server tools."""
|
||||
|
||||
import json
|
||||
|
||||
from finn_eiendom.mcp_server import (
|
||||
finn_decode_unit_vector,
|
||||
mcp,
|
||||
)
|
||||
|
||||
|
||||
def test_mcp_server_has_correct_tools():
|
||||
"""Assert that the MCP server has all expected tools."""
|
||||
import asyncio
|
||||
|
||||
async def check_tools():
|
||||
tools = await mcp.list_tools()
|
||||
tool_names = {tool.name for tool in tools}
|
||||
expected_tools = {
|
||||
"finn_analyze_search",
|
||||
"finn_get_ad",
|
||||
"finn_resolve_eiendom_unit",
|
||||
"finn_get_eiendom_unit",
|
||||
"finn_get_similar_units",
|
||||
"finn_build_unit_vector",
|
||||
"finn_decode_unit_vector",
|
||||
}
|
||||
assert expected_tools.issubset(tool_names), f"Missing tools: {expected_tools - tool_names}"
|
||||
|
||||
asyncio.run(check_tools())
|
||||
|
||||
|
||||
def test_finn_decode_unit_vector_returns_json():
|
||||
"""Test that finn_decode_unit_vector returns valid JSON with expected keys."""
|
||||
from unittest.mock import patch
|
||||
|
||||
test_vector = {
|
||||
"lon": 10.7,
|
||||
"lat": 59.9,
|
||||
"ptype": "APARTMENT",
|
||||
"floor": 3,
|
||||
"rooms": 3,
|
||||
"built": 2000,
|
||||
"area": 80,
|
||||
"price": 5000000,
|
||||
}
|
||||
|
||||
with patch("finn_eiendom.mcp_server.decode_unit_vector", return_value=test_vector):
|
||||
result = finn_decode_unit_vector("dGVzdA==")
|
||||
|
||||
data = json.loads(result)
|
||||
assert "lon" in data
|
||||
assert "lat" in data
|
||||
assert "ptype" in data
|
||||
assert data["lat"] == 59.9
|
||||
assert data["lon"] == 10.7
|
||||
|
||||
|
||||
def test_finn_decode_unit_vector_error_handling():
|
||||
"""Test that finn_decode_unit_vector handles errors gracefully."""
|
||||
from unittest.mock import patch
|
||||
|
||||
with patch(
|
||||
"finn_eiendom.mcp_server.decode_unit_vector", side_effect=Exception("decode failed")
|
||||
):
|
||||
result = finn_decode_unit_vector("invalid")
|
||||
|
||||
data = json.loads(result)
|
||||
assert data.get("error") is True
|
||||
assert "message" in data
|
||||
@@ -0,0 +1,45 @@
|
||||
from finn_eiendom.parser import (
|
||||
clean_text,
|
||||
extract_finnkode_from_url,
|
||||
normalize_area,
|
||||
normalize_finnkode,
|
||||
normalize_number,
|
||||
normalize_price,
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_price():
|
||||
assert normalize_price("7 200 991 kr") == 7200991
|
||||
assert normalize_price("1 234") == 1234
|
||||
assert normalize_price(None) is None
|
||||
|
||||
|
||||
def test_normalize_area():
|
||||
assert normalize_area("77 m²") == 77
|
||||
assert normalize_area("100,5 m²") == 100
|
||||
assert normalize_area("") is None
|
||||
|
||||
|
||||
def test_normalize_number():
|
||||
assert normalize_number("3 500 kr/mnd") == 3500
|
||||
assert normalize_number("7,2") == 7
|
||||
assert normalize_number("1.234") == 1234
|
||||
assert normalize_number(None) is None
|
||||
|
||||
|
||||
def test_normalize_finnkode():
|
||||
assert normalize_finnkode(" 462400360 ") == "462400360"
|
||||
assert normalize_finnkode(None) is None
|
||||
|
||||
|
||||
def test_extract_finnkode_from_url():
|
||||
assert (
|
||||
extract_finnkode_from_url("https://www.finn.no/realestate/homes/ad.html?finnkode=462400360")
|
||||
== "462400360"
|
||||
)
|
||||
assert extract_finnkode_from_url("https://www.finn.no/realestate/homes/ad.html") is None
|
||||
|
||||
|
||||
def test_clean_text():
|
||||
assert clean_text(" Hello world \n") == "Hello world"
|
||||
assert clean_text(None) is None
|
||||
@@ -0,0 +1,22 @@
|
||||
from finn_eiendom.models import EiendomUnit, FinnAd
|
||||
from finn_eiendom.scoring import classify_ad, score_ad
|
||||
|
||||
|
||||
def test_score_ad_and_classify():
|
||||
ad = FinnAd(
|
||||
finnkode="462400360",
|
||||
url="https://www.finn.no/realestate/homes/ad.html?finnkode=462400360",
|
||||
title="Flott 3-roms i Ferner",
|
||||
)
|
||||
unit = EiendomUnit(
|
||||
unit_code="c-gxw-xmyum-s2a",
|
||||
estimated_selling_price=7650000,
|
||||
listing_price=7200000,
|
||||
property_type="APARTMENT",
|
||||
usable_area=77,
|
||||
rooms=4,
|
||||
)
|
||||
scores = score_ad(ad, unit, [])
|
||||
assert scores["market_position"] >= 0
|
||||
categories = classify_ad(scores)
|
||||
assert isinstance(categories, list)
|
||||
@@ -0,0 +1,38 @@
|
||||
from finn_eiendom.search import extract_ad_links, extract_search_cards
|
||||
from tests.fixtures import SAMPLE_FINN_SEARCH_HTML, SAMPLE_FINN_SEARCH_HTML_NEW
|
||||
|
||||
|
||||
def test_extract_search_cards():
|
||||
cards = extract_search_cards(SAMPLE_FINN_SEARCH_HTML)
|
||||
assert len(cards) == 2
|
||||
assert cards[0].finnkode == "462400360"
|
||||
assert cards[0].url.endswith("finnkode=462400360")
|
||||
assert cards[0].area_m2 == 77
|
||||
assert cards[0].total_price == 7200991
|
||||
assert cards[0].common_costs == 3500
|
||||
assert cards[1].bedrooms == 2
|
||||
|
||||
|
||||
def test_extract_search_cards_new_format():
|
||||
cards = extract_search_cards(SAMPLE_FINN_SEARCH_HTML_NEW)
|
||||
assert len(cards) == 1
|
||||
assert cards[0].finnkode == "462880791"
|
||||
assert cards[0].url.endswith("finnkode=462880791")
|
||||
assert cards[0].address == "Lofotgata 4B, Oslo"
|
||||
assert cards[0].area_m2 == 62
|
||||
assert cards[0].total_price == 7253377
|
||||
assert cards[0].common_costs == 7067
|
||||
assert cards[0].bedrooms == 2
|
||||
|
||||
|
||||
def test_extract_ad_links():
|
||||
links = extract_ad_links(SAMPLE_FINN_SEARCH_HTML)
|
||||
assert len(links) == 2
|
||||
assert "finnkode=462400360" in links[0]
|
||||
assert "finnkode=460784945" in links[1]
|
||||
|
||||
|
||||
def test_extract_ad_links_new_format():
|
||||
links = extract_ad_links(SAMPLE_FINN_SEARCH_HTML_NEW)
|
||||
assert len(links) == 1
|
||||
assert "finnkode=462880791" in links[0]
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Tests for the service layer (cache-aware fetching)."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from finn_eiendom.models import EiendomUnit, FinnAd
|
||||
from finn_eiendom.service import get_or_fetch_ad, get_or_fetch_eiendom_unit
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_or_fetch_ad_uses_cache():
|
||||
"""Test that get_or_fetch_ad returns cached ad without fetching."""
|
||||
mock_ad = FinnAd(finnkode="123", url="http://example.com")
|
||||
|
||||
with (
|
||||
patch("finn_eiendom.service.init_db"),
|
||||
patch("finn_eiendom.service.get_finn_ad", return_value=mock_ad) as mock_get,
|
||||
patch("finn_eiendom.service.fetch_ad_details") as mock_fetch,
|
||||
):
|
||||
result = await get_or_fetch_ad("123")
|
||||
|
||||
assert result.finnkode == "123"
|
||||
mock_get.assert_called_once()
|
||||
mock_fetch.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_or_fetch_ad_fetches_when_cache_miss():
|
||||
"""Test that get_or_fetch_ad fetches when cache is empty."""
|
||||
mock_ad = FinnAd(finnkode="123", url="http://example.com")
|
||||
|
||||
with (
|
||||
patch("finn_eiendom.service.init_db"),
|
||||
patch("finn_eiendom.service.get_finn_ad", return_value=None),
|
||||
patch("finn_eiendom.service.fetch_ad_details", return_value=mock_ad) as mock_fetch,
|
||||
patch("finn_eiendom.service.save_finn_ad") as mock_save,
|
||||
):
|
||||
result = await get_or_fetch_ad("123")
|
||||
|
||||
assert result.finnkode == "123"
|
||||
mock_fetch.assert_called_once_with("123")
|
||||
mock_save.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_or_fetch_ad_force_refresh():
|
||||
"""Test that force_refresh=True bypasses cache."""
|
||||
mock_ad = FinnAd(finnkode="123", url="http://example.com")
|
||||
|
||||
with (
|
||||
patch("finn_eiendom.service.init_db"),
|
||||
patch("finn_eiendom.service.get_finn_ad", return_value=mock_ad) as mock_get,
|
||||
patch("finn_eiendom.service.fetch_ad_details", return_value=mock_ad) as mock_fetch,
|
||||
patch("finn_eiendom.service.save_finn_ad") as mock_save,
|
||||
):
|
||||
result = await get_or_fetch_ad("123", force_refresh=True)
|
||||
|
||||
assert result.finnkode == "123"
|
||||
mock_get.assert_not_called()
|
||||
mock_fetch.assert_called_once_with("123")
|
||||
mock_save.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_or_fetch_eiendom_unit_uses_cache():
|
||||
"""Test that get_or_fetch_eiendom_unit returns cached unit without fetching."""
|
||||
mock_unit = EiendomUnit(unit_code="test-code")
|
||||
|
||||
with (
|
||||
patch("finn_eiendom.service.init_db"),
|
||||
patch("finn_eiendom.service.get_cached_eiendom_unit", return_value=mock_unit) as mock_get,
|
||||
patch("finn_eiendom.service.get_unit") as mock_fetch,
|
||||
):
|
||||
result = await get_or_fetch_eiendom_unit("test-code")
|
||||
|
||||
assert result.unit_code == "test-code"
|
||||
mock_get.assert_called_once()
|
||||
mock_fetch.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_or_fetch_eiendom_unit_fetches_when_cache_miss():
|
||||
"""Test that get_or_fetch_eiendom_unit fetches when cache is empty."""
|
||||
mock_unit = EiendomUnit(unit_code="test-code")
|
||||
|
||||
with (
|
||||
patch("finn_eiendom.service.init_db"),
|
||||
patch("finn_eiendom.service.get_cached_eiendom_unit", return_value=None),
|
||||
patch("finn_eiendom.service.get_unit", return_value=mock_unit) as mock_fetch,
|
||||
patch("finn_eiendom.service.save_eiendom_unit") as mock_save,
|
||||
):
|
||||
result = await get_or_fetch_eiendom_unit("test-code")
|
||||
|
||||
assert result.unit_code == "test-code"
|
||||
mock_fetch.assert_called_once_with("test-code")
|
||||
mock_save.assert_called_once()
|
||||
Reference in New Issue
Block a user