Add tests for CLI entry point and scoring functionality; enhance service layer tests for similar units
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Vendored
+4
-1
@@ -21,6 +21,9 @@
|
|||||||
"**/.ruff_cache": true
|
"**/.ruff_cache": true
|
||||||
},
|
},
|
||||||
"chat.tools.terminal.autoApprove": {
|
"chat.tools.terminal.autoApprove": {
|
||||||
"/root/projects/finn-mcp/.venv/bin/python": true
|
"/root/projects/finn-mcp/.venv/bin/python": true,
|
||||||
|
"make": true,
|
||||||
|
".venv/bin/coverage": true,
|
||||||
|
".venv/bin/pytest": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -22,6 +22,13 @@ test: ## Run the full test suite
|
|||||||
test-fast: ## Run tests, fail fast, verbose
|
test-fast: ## Run tests, fail fast, verbose
|
||||||
$(BIN)/pytest -x -v
|
$(BIN)/pytest -x -v
|
||||||
|
|
||||||
|
coverage: ## Run tests with coverage report
|
||||||
|
$(BIN)/pytest --cov=finn_eiendom --cov-report=term-missing --cov-report=html
|
||||||
|
@echo "Open htmlcov/index.html to browse coverage"
|
||||||
|
|
||||||
|
coverage-check: ## Fail if coverage below 100%
|
||||||
|
$(BIN)/pytest --cov=finn_eiendom --cov-fail-under=100
|
||||||
|
|
||||||
lint: ## Lint with ruff
|
lint: ## Lint with ruff
|
||||||
$(BIN)/ruff check .
|
$(BIN)/ruff check .
|
||||||
|
|
||||||
@@ -45,3 +52,10 @@ mcp: ## Start the MCP server over stdio
|
|||||||
|
|
||||||
doctor: ## Smoke-check the install
|
doctor: ## Smoke-check the install
|
||||||
$(BIN)/finn-eiendom doctor
|
$(BIN)/finn-eiendom doctor
|
||||||
|
|
||||||
|
docker: ## Build a Docker image for the MCP server
|
||||||
|
docker build -t finn-mcp:latest .
|
||||||
|
|
||||||
|
docker-run: docker ## Run the MCP server in Docker, exposing port 8010
|
||||||
|
docker compose -f docker-compose.prod.yml down
|
||||||
|
docker compose -f docker-compose.prod.yml up -d
|
||||||
+8
-1
@@ -51,4 +51,11 @@ asyncio_mode = "auto"
|
|||||||
[tool.mypy]
|
[tool.mypy]
|
||||||
python_version = "3.12"
|
python_version = "3.12"
|
||||||
strict = true
|
strict = true
|
||||||
plugins = []
|
plugins = []
|
||||||
|
|
||||||
|
[tool.coverage.run]
|
||||||
|
branch = true
|
||||||
|
omit = ["*/tests/*"]
|
||||||
|
|
||||||
|
[tool.coverage.report]
|
||||||
|
fail_under = 100
|
||||||
|
|||||||
@@ -0,0 +1,729 @@
|
|||||||
|
"""Tests for the typer-based CLI."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
|
from finn_eiendom.cli import (
|
||||||
|
app,
|
||||||
|
analyze_search,
|
||||||
|
enrich_ad,
|
||||||
|
compare,
|
||||||
|
get_ad,
|
||||||
|
analyze_against_comps,
|
||||||
|
resolve_unit,
|
||||||
|
get_unit,
|
||||||
|
build_vector,
|
||||||
|
decode_vector,
|
||||||
|
similar_units,
|
||||||
|
similar_to_liked,
|
||||||
|
save_feedback,
|
||||||
|
shortlist,
|
||||||
|
diff,
|
||||||
|
version,
|
||||||
|
serve,
|
||||||
|
)
|
||||||
|
from finn_eiendom.models import (
|
||||||
|
EiendomUnit,
|
||||||
|
FinnAd,
|
||||||
|
SimilarUnit,
|
||||||
|
)
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Version and utility commands
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def test_version_command():
|
||||||
|
"""Test the version command."""
|
||||||
|
result = runner.invoke(app, ["version"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "0.1.0" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Search and analysis commands
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def test_analyze_search_with_exception():
|
||||||
|
"""Test analyze_search handles exceptions gracefully."""
|
||||||
|
with patch("finn_eiendom.cli.svc_analyze_search") as mock_analyze:
|
||||||
|
mock_analyze.side_effect = RuntimeError("Test error")
|
||||||
|
result = runner.invoke(app, ["analyze-search", "http://example.com"])
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "Error:" in result.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_analyze_search_success():
|
||||||
|
"""Test analyze_search with successful result."""
|
||||||
|
mock_result = {
|
||||||
|
"listings": [
|
||||||
|
{
|
||||||
|
"finnkode": "123",
|
||||||
|
"address": "Test St 1",
|
||||||
|
"title": "Test Property",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("finn_eiendom.cli.svc_analyze_search") as mock_analyze,
|
||||||
|
patch("finn_eiendom.cli.formatting.render_shortlist") as mock_render,
|
||||||
|
):
|
||||||
|
mock_analyze.return_value = mock_result
|
||||||
|
mock_render.return_value = '{"listings": []}'
|
||||||
|
result = runner.invoke(app, ["analyze-search", "http://example.com"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
mock_analyze.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_analyze_search_with_options():
|
||||||
|
"""Test analyze_search with various options."""
|
||||||
|
mock_result = {"listings": []}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("finn_eiendom.cli.svc_analyze_search") as mock_analyze,
|
||||||
|
patch("finn_eiendom.cli.formatting.render_shortlist") as mock_render,
|
||||||
|
):
|
||||||
|
mock_analyze.return_value = mock_result
|
||||||
|
mock_render.return_value = "{}"
|
||||||
|
result = runner.invoke(
|
||||||
|
app,
|
||||||
|
[
|
||||||
|
"analyze-search",
|
||||||
|
"http://example.com",
|
||||||
|
"--max-pages",
|
||||||
|
"5",
|
||||||
|
"--detail-limit",
|
||||||
|
"50",
|
||||||
|
"--no-details",
|
||||||
|
"--no-eiendom",
|
||||||
|
"--with-similar",
|
||||||
|
"--format",
|
||||||
|
"json",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
mock_analyze.assert_called_once()
|
||||||
|
call_args = mock_analyze.call_args
|
||||||
|
assert call_args[0][0] == "http://example.com"
|
||||||
|
assert call_args[1]["max_pages"] == 5
|
||||||
|
assert call_args[1]["detail_limit"] == 50
|
||||||
|
assert call_args[1]["include_details"] is False
|
||||||
|
assert call_args[1]["include_eiendom_no"] is False
|
||||||
|
assert call_args[1]["include_similar_units_for_shortlist"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_ad_success():
|
||||||
|
"""Test get_ad command with successful result."""
|
||||||
|
mock_ad = FinnAd(finnkode="123", url="http://example.com", title="Test")
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("finn_eiendom.cli.svc_get_or_fetch_ad") as mock_get,
|
||||||
|
patch("finn_eiendom.cli.formatting.render_ad") as mock_render,
|
||||||
|
):
|
||||||
|
mock_get.return_value = mock_ad
|
||||||
|
mock_render.return_value = '{"finnkode": "123"}'
|
||||||
|
result = runner.invoke(app, ["get-ad", "123"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
mock_get.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_ad_with_force_refresh():
|
||||||
|
"""Test get_ad with force_refresh option."""
|
||||||
|
mock_ad = FinnAd(finnkode="123", url="http://example.com", title="Test")
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("finn_eiendom.cli.svc_get_or_fetch_ad") as mock_get,
|
||||||
|
patch("finn_eiendom.cli.formatting.render_ad") as mock_render,
|
||||||
|
):
|
||||||
|
mock_get.return_value = mock_ad
|
||||||
|
mock_render.return_value = "{}"
|
||||||
|
result = runner.invoke(app, ["get-ad", "123", "--force-refresh"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
call_args = mock_get.call_args
|
||||||
|
assert call_args[1]["force_refresh"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_ad_error():
|
||||||
|
"""Test get_ad handles errors."""
|
||||||
|
with patch("finn_eiendom.cli.svc_get_or_fetch_ad") as mock_get:
|
||||||
|
mock_get.side_effect = ValueError("Not found")
|
||||||
|
result = runner.invoke(app, ["get-ad", "999"])
|
||||||
|
assert result.exit_code == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_enrich_ad_success():
|
||||||
|
"""Test enrich_ad command."""
|
||||||
|
with (
|
||||||
|
patch("finn_eiendom.cli.svc_analyze_ad") as mock_analyze,
|
||||||
|
patch("finn_eiendom.cli.formatting.render_ad") as mock_render,
|
||||||
|
):
|
||||||
|
mock_analyze.return_value = {"ad": {"finnkode": "123"}}
|
||||||
|
mock_render.return_value = "{}"
|
||||||
|
result = runner.invoke(app, ["enrich-ad", "123"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
mock_analyze.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_compare_success():
|
||||||
|
"""Test compare command with valid input."""
|
||||||
|
with (
|
||||||
|
patch("finn_eiendom.cli.svc_compare_ads") as mock_compare,
|
||||||
|
patch("finn_eiendom.cli.formatting.render_comparison") as mock_render,
|
||||||
|
):
|
||||||
|
mock_compare.return_value = {"listings": []}
|
||||||
|
mock_render.return_value = "{}"
|
||||||
|
result = runner.invoke(app, ["compare", "123", "456"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
mock_compare.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_compare_too_few_args():
|
||||||
|
"""Test compare rejects less than 2 finnkoder."""
|
||||||
|
result = runner.invoke(app, ["compare", "123"])
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "at least 2" in result.stderr.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_compare_too_many_args():
|
||||||
|
"""Test compare rejects more than 10 finnkoder."""
|
||||||
|
finnkoder = [str(i) for i in range(11)]
|
||||||
|
result = runner.invoke(app, ["compare"] + finnkoder)
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "at most 10" in result.stdout.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_compare_with_options():
|
||||||
|
"""Test compare with various options."""
|
||||||
|
with (
|
||||||
|
patch("finn_eiendom.cli.svc_compare_ads") as mock_compare,
|
||||||
|
patch("finn_eiendom.cli.formatting.render_comparison") as mock_render,
|
||||||
|
):
|
||||||
|
mock_compare.return_value = {"listings": []}
|
||||||
|
mock_render.return_value = "{}"
|
||||||
|
result = runner.invoke(
|
||||||
|
app,
|
||||||
|
[
|
||||||
|
"compare",
|
||||||
|
"123",
|
||||||
|
"456",
|
||||||
|
"--no-eiendom",
|
||||||
|
"--no-comps",
|
||||||
|
"--format",
|
||||||
|
"json",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
call_args = mock_compare.call_args
|
||||||
|
assert call_args[1]["include_eiendom_no"] is False
|
||||||
|
assert call_args[1]["include_comps"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_analyze_against_comps_success():
|
||||||
|
"""Test analyze_against_comps command."""
|
||||||
|
with (
|
||||||
|
patch("finn_eiendom.cli.svc_analyze_ad_against_comps") as mock_analyze,
|
||||||
|
patch("finn_eiendom.cli.formatting.render_comparison") as mock_render,
|
||||||
|
):
|
||||||
|
mock_analyze.return_value = {"listings": []}
|
||||||
|
mock_render.return_value = "{}"
|
||||||
|
result = runner.invoke(app, ["analyze-against-comps", "123"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
mock_analyze.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_analyze_against_comps_with_status():
|
||||||
|
"""Test analyze_against_comps with listing_status option."""
|
||||||
|
with (
|
||||||
|
patch("finn_eiendom.cli.svc_analyze_ad_against_comps") as mock_analyze,
|
||||||
|
patch("finn_eiendom.cli.formatting.render_comparison") as mock_render,
|
||||||
|
):
|
||||||
|
mock_analyze.return_value = {}
|
||||||
|
mock_render.return_value = "{}"
|
||||||
|
result = runner.invoke(
|
||||||
|
app,
|
||||||
|
["analyze-against-comps", "123", "--listing-status", "FOR_SALE"],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
call_args = mock_analyze.call_args
|
||||||
|
assert call_args[1]["listing_status"] == "FOR_SALE"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Eiendom.no commands
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_unit_success():
|
||||||
|
"""Test resolve_unit command."""
|
||||||
|
mock_unit = EiendomUnit(
|
||||||
|
unit_code="test-code",
|
||||||
|
lat=59.9,
|
||||||
|
lng=10.7,
|
||||||
|
property_type="APARTMENT",
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("finn_eiendom.cli.svc_resolve_eiendom_unit") as mock_resolve,
|
||||||
|
patch("finn_eiendom.cli.formatting.render_unit") as mock_render,
|
||||||
|
):
|
||||||
|
mock_resolve.return_value = mock_unit
|
||||||
|
mock_render.return_value = "{}"
|
||||||
|
result = runner.invoke(app, ["resolve-unit", "http://example.com"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_unit_not_found():
|
||||||
|
"""Test resolve_unit when unit not found."""
|
||||||
|
with patch("finn_eiendom.cli.svc_resolve_eiendom_unit") as mock_resolve:
|
||||||
|
mock_resolve.return_value = None
|
||||||
|
result = runner.invoke(app, ["resolve-unit", "http://example.com"])
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "could not resolve" in result.stdout.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_unit_error():
|
||||||
|
"""Test resolve_unit handles exceptions."""
|
||||||
|
with patch("finn_eiendom.cli.svc_resolve_eiendom_unit") as mock_resolve:
|
||||||
|
mock_resolve.side_effect = RuntimeError("Network error")
|
||||||
|
result = runner.invoke(app, ["resolve-unit", "http://example.com"])
|
||||||
|
assert result.exit_code == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_unit_success():
|
||||||
|
"""Test get_unit command."""
|
||||||
|
mock_unit = EiendomUnit(
|
||||||
|
unit_code="test-code",
|
||||||
|
property_type="APARTMENT",
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("finn_eiendom.cli.svc_get_or_fetch_eiendom_unit") as mock_get,
|
||||||
|
patch("finn_eiendom.cli.formatting.render_unit") as mock_render,
|
||||||
|
):
|
||||||
|
mock_get.return_value = mock_unit
|
||||||
|
mock_render.return_value = "{}"
|
||||||
|
result = runner.invoke(app, ["get-unit", "test-code"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_unit_with_force_refresh():
|
||||||
|
"""Test get_unit with force_refresh option."""
|
||||||
|
mock_unit = EiendomUnit(unit_code="test-code", property_type="APARTMENT")
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("finn_eiendom.cli.svc_get_or_fetch_eiendom_unit") as mock_get,
|
||||||
|
patch("finn_eiendom.cli.formatting.render_unit") as mock_render,
|
||||||
|
):
|
||||||
|
mock_get.return_value = mock_unit
|
||||||
|
mock_render.return_value = "{}"
|
||||||
|
result = runner.invoke(app, ["get-unit", "test-code", "--force-refresh"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
call_args = mock_get.call_args
|
||||||
|
assert call_args[1]["force_refresh"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_unit_not_found():
|
||||||
|
"""Test get_unit when unit not found."""
|
||||||
|
with patch("finn_eiendom.cli.svc_get_or_fetch_eiendom_unit") as mock_get:
|
||||||
|
mock_get.return_value = None
|
||||||
|
result = runner.invoke(app, ["get-unit", "test-code"])
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "not found" in result.stdout.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_vector_success():
|
||||||
|
"""Test build_vector command."""
|
||||||
|
with (
|
||||||
|
patch("finn_eiendom.cli.svc_build_unit_vector") as mock_build,
|
||||||
|
patch("finn_eiendom.cli.formatting.render_ad") as mock_render,
|
||||||
|
):
|
||||||
|
mock_build.return_value = {"unit_vector": "encoded"}
|
||||||
|
mock_render.return_value = "{}"
|
||||||
|
result = runner.invoke(app, ["build-vector", "test-code"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_vector_error():
|
||||||
|
"""Test build_vector handles exceptions."""
|
||||||
|
with patch("finn_eiendom.cli.svc_build_unit_vector") as mock_build:
|
||||||
|
mock_build.side_effect = ValueError("Invalid unit code")
|
||||||
|
result = runner.invoke(app, ["build-vector", "invalid"])
|
||||||
|
assert result.exit_code == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_decode_vector_success():
|
||||||
|
"""Test decode_vector command."""
|
||||||
|
with (
|
||||||
|
patch("finn_eiendom.cli.svc_decode_unit_vector") as mock_decode,
|
||||||
|
patch("finn_eiendom.cli.formatting.render_ad") as mock_render,
|
||||||
|
):
|
||||||
|
mock_decode.return_value = {"lat": 59.9, "lng": 10.7}
|
||||||
|
mock_render.return_value = "{}"
|
||||||
|
result = runner.invoke(app, ["decode-vector", "encoded-vector"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_decode_vector_error():
|
||||||
|
"""Test decode_vector handles exceptions."""
|
||||||
|
with patch("finn_eiendom.cli.svc_decode_unit_vector") as mock_decode:
|
||||||
|
mock_decode.side_effect = ValueError("Invalid vector")
|
||||||
|
result = runner.invoke(app, ["decode-vector", "invalid"])
|
||||||
|
assert result.exit_code == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_similar_units_success():
|
||||||
|
"""Test similar_units command."""
|
||||||
|
mock_units = [
|
||||||
|
SimilarUnit(
|
||||||
|
unit_code="u1",
|
||||||
|
address="Test St 1",
|
||||||
|
listing_price=5000000,
|
||||||
|
area=80,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("finn_eiendom.cli.svc_get_or_fetch_similar_units") as mock_get,
|
||||||
|
patch("finn_eiendom.cli.formatting.render_similar_units") as mock_render,
|
||||||
|
):
|
||||||
|
mock_get.return_value = mock_units
|
||||||
|
mock_render.return_value = "{}"
|
||||||
|
result = runner.invoke(app, ["similar-units", "test-code"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_similar_units_with_status():
|
||||||
|
"""Test similar_units with listing_status option."""
|
||||||
|
with (
|
||||||
|
patch("finn_eiendom.cli.svc_get_or_fetch_similar_units") as mock_get,
|
||||||
|
patch("finn_eiendom.cli.formatting.render_similar_units") as mock_render,
|
||||||
|
):
|
||||||
|
mock_get.return_value = []
|
||||||
|
mock_render.return_value = "{}"
|
||||||
|
result = runner.invoke(
|
||||||
|
app,
|
||||||
|
["similar-units", "test-code", "--status", "FOR_SALE"],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
call_args = mock_get.call_args
|
||||||
|
assert call_args[1]["listing_status"] == "FOR_SALE"
|
||||||
|
|
||||||
|
|
||||||
|
def test_similar_units_error():
|
||||||
|
"""Test similar_units handles exceptions."""
|
||||||
|
with patch("finn_eiendom.cli.svc_get_or_fetch_similar_units") as mock_get:
|
||||||
|
mock_get.side_effect = RuntimeError("API error")
|
||||||
|
result = runner.invoke(app, ["similar-units", "test-code"])
|
||||||
|
assert result.exit_code == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_similar_to_liked_success():
|
||||||
|
"""Test similar_to_liked command."""
|
||||||
|
with (
|
||||||
|
patch("finn_eiendom.cli.svc_find_similar_to_liked") as mock_find,
|
||||||
|
patch("finn_eiendom.cli.formatting.render_similar_units") as mock_render,
|
||||||
|
):
|
||||||
|
mock_find.return_value = {"similar_units": []}
|
||||||
|
mock_render.return_value = "{}"
|
||||||
|
result = runner.invoke(app, ["similar-to-liked", "123"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_similar_to_liked_with_options():
|
||||||
|
"""Test similar_to_liked with mode and status options."""
|
||||||
|
with (
|
||||||
|
patch("finn_eiendom.cli.svc_find_similar_to_liked") as mock_find,
|
||||||
|
patch("finn_eiendom.cli.formatting.render_similar_units") as mock_render,
|
||||||
|
):
|
||||||
|
mock_find.return_value = {}
|
||||||
|
mock_render.return_value = "{}"
|
||||||
|
result = runner.invoke(
|
||||||
|
app,
|
||||||
|
[
|
||||||
|
"similar-to-liked",
|
||||||
|
"123",
|
||||||
|
"--mode",
|
||||||
|
"comps",
|
||||||
|
"--status",
|
||||||
|
"RECENTLY_SOLD",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
call_args = mock_find.call_args
|
||||||
|
assert call_args[1]["mode"] == "comps"
|
||||||
|
assert call_args[1]["listing_status"] == "RECENTLY_SOLD"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Feedback commands
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_feedback_success():
|
||||||
|
"""Test save_feedback command."""
|
||||||
|
with patch("finn_eiendom.cli.svc_save_feedback") as mock_save:
|
||||||
|
mock_save.return_value = None
|
||||||
|
result = runner.invoke(app, ["save-feedback", "123", "liked"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Saved feedback" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_feedback_with_notes():
|
||||||
|
"""Test save_feedback with notes option."""
|
||||||
|
with patch("finn_eiendom.cli.svc_save_feedback") as mock_save:
|
||||||
|
mock_save.return_value = None
|
||||||
|
result = runner.invoke(
|
||||||
|
app,
|
||||||
|
[
|
||||||
|
"save-feedback",
|
||||||
|
"123",
|
||||||
|
"liked",
|
||||||
|
"--notes",
|
||||||
|
"Great location",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
call_args = mock_save.call_args
|
||||||
|
assert call_args[0][0] == "123"
|
||||||
|
assert call_args[0][1] == "liked"
|
||||||
|
assert call_args[0][2] == "Great location"
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_feedback_error():
|
||||||
|
"""Test save_feedback handles exceptions."""
|
||||||
|
with patch("finn_eiendom.cli.svc_save_feedback") as mock_save:
|
||||||
|
mock_save.side_effect = ValueError("Invalid verdict")
|
||||||
|
result = runner.invoke(app, ["save-feedback", "123", "invalid"])
|
||||||
|
assert result.exit_code == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# History and diff commands
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def test_shortlist_success():
|
||||||
|
"""Test shortlist command."""
|
||||||
|
with (
|
||||||
|
patch("finn_eiendom.cli.svc_get_shortlist") as mock_get,
|
||||||
|
patch("finn_eiendom.cli.formatting.render_shortlist") as mock_render,
|
||||||
|
):
|
||||||
|
mock_get.return_value = {"listings": []}
|
||||||
|
mock_render.return_value = "{}"
|
||||||
|
result = runner.invoke(app, ["shortlist"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_shortlist_with_run_id():
|
||||||
|
"""Test shortlist with run_id option."""
|
||||||
|
with (
|
||||||
|
patch("finn_eiendom.cli.svc_get_shortlist") as mock_get,
|
||||||
|
patch("finn_eiendom.cli.formatting.render_shortlist") as mock_render,
|
||||||
|
):
|
||||||
|
mock_get.return_value = {}
|
||||||
|
mock_render.return_value = "{}"
|
||||||
|
result = runner.invoke(app, ["shortlist", "--run-id", "42"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
call_args = mock_get.call_args
|
||||||
|
assert call_args[0][0] == 42
|
||||||
|
|
||||||
|
|
||||||
|
def test_shortlist_with_limit():
|
||||||
|
"""Test shortlist with limit option."""
|
||||||
|
with (
|
||||||
|
patch("finn_eiendom.cli.svc_get_shortlist") as mock_get,
|
||||||
|
patch("finn_eiendom.cli.formatting.render_shortlist") as mock_render,
|
||||||
|
):
|
||||||
|
mock_get.return_value = {}
|
||||||
|
mock_render.return_value = "{}"
|
||||||
|
result = runner.invoke(app, ["shortlist", "--limit", "20"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
call_args = mock_get.call_args
|
||||||
|
assert call_args[1]["limit"] == 20
|
||||||
|
|
||||||
|
|
||||||
|
def test_diff_success():
|
||||||
|
"""Test diff command."""
|
||||||
|
with (
|
||||||
|
patch("finn_eiendom.cli.svc_get_new_ads_since_last_run") as mock_diff,
|
||||||
|
patch("finn_eiendom.cli.formatting.render_diff") as mock_render,
|
||||||
|
):
|
||||||
|
mock_diff.return_value = {"new": [], "removed": [], "changed": []}
|
||||||
|
mock_render.return_value = "{}"
|
||||||
|
result = runner.invoke(app, ["diff", "http://example.com"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_diff_error():
|
||||||
|
"""Test diff handles exceptions."""
|
||||||
|
with patch("finn_eiendom.cli.svc_get_new_ads_since_last_run") as mock_diff:
|
||||||
|
mock_diff.side_effect = RuntimeError("Fetch error")
|
||||||
|
result = runner.invoke(app, ["diff", "http://example.com"])
|
||||||
|
assert result.exit_code == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Cache management commands
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def test_cache_stats():
|
||||||
|
"""Test cache stats command."""
|
||||||
|
result = runner.invoke(app, ["cache", "stats"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Cache stats" in result.stdout or "not yet implemented" in result.stdout.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_cache_clear_confirm_yes():
|
||||||
|
"""Test cache clear with confirmation."""
|
||||||
|
result = runner.invoke(app, ["cache", "clear"], input="y\n")
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_cache_clear_confirm_no():
|
||||||
|
"""Test cache clear with confirmation rejected."""
|
||||||
|
result = runner.invoke(app, ["cache", "clear"], input="n\n")
|
||||||
|
assert result.exit_code == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_cache_clear_html():
|
||||||
|
"""Test cache clear HTML command."""
|
||||||
|
result = runner.invoke(app, ["cache", "clear-html"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_cache_clear_json():
|
||||||
|
"""Test cache clear JSON command."""
|
||||||
|
result = runner.invoke(app, ["cache", "clear-json"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Config management commands
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_show():
|
||||||
|
"""Test config show command."""
|
||||||
|
result = runner.invoke(app, ["config", "show"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Cache path" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_path():
|
||||||
|
"""Test config path command."""
|
||||||
|
result = runner.invoke(app, ["config", "path"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
# Should output a path
|
||||||
|
assert len(result.stdout.strip()) > 0
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Serve command
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def test_serve_stdio():
|
||||||
|
"""Test serve command with stdio transport."""
|
||||||
|
with patch("finn_eiendom.cli.mcp_main") as mock_mcp:
|
||||||
|
result = runner.invoke(app, ["serve", "--transport", "stdio"])
|
||||||
|
# Should call the MCP main
|
||||||
|
assert result.exit_code == 0 or "Error" not in result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_serve_http_not_implemented():
|
||||||
|
"""Test serve command with HTTP transport (not yet implemented)."""
|
||||||
|
result = runner.invoke(app, ["serve", "--transport", "http", "--port", "8010"])
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "not yet implemented" in result.stdout.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_serve_unknown_transport():
|
||||||
|
"""Test serve command with unknown transport."""
|
||||||
|
result = runner.invoke(app, ["serve", "--transport", "unknown"])
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "unknown transport" in result.stdout.lower()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Output format options
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_commands_support_format_json():
|
||||||
|
"""Test that output-producing commands support --format json."""
|
||||||
|
with (
|
||||||
|
patch("finn_eiendom.cli.svc_analyze_search") as mock,
|
||||||
|
patch("finn_eiendom.cli.formatting.render_shortlist") as mock_render,
|
||||||
|
):
|
||||||
|
mock.return_value = {}
|
||||||
|
mock_render.return_value = "{}"
|
||||||
|
result = runner.invoke(
|
||||||
|
app,
|
||||||
|
["analyze-search", "http://example.com", "--format", "json"],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_commands_support_format_markdown():
|
||||||
|
"""Test that output-producing commands support --format markdown."""
|
||||||
|
with (
|
||||||
|
patch("finn_eiendom.cli.svc_analyze_search") as mock,
|
||||||
|
patch("finn_eiendom.cli.formatting.render_shortlist") as mock_render,
|
||||||
|
):
|
||||||
|
mock.return_value = {}
|
||||||
|
mock_render.return_value = "# Markdown"
|
||||||
|
result = runner.invoke(
|
||||||
|
app,
|
||||||
|
["analyze-search", "http://example.com", "--format", "markdown"],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_commands_support_format_table():
|
||||||
|
"""Test that output-producing commands support --format table."""
|
||||||
|
with (
|
||||||
|
patch("finn_eiendom.cli.svc_analyze_search") as mock,
|
||||||
|
patch("finn_eiendom.cli.formatting.render_shortlist") as mock_render,
|
||||||
|
):
|
||||||
|
mock.return_value = {}
|
||||||
|
mock_render.return_value = "| col1 | col2 |"
|
||||||
|
result = runner.invoke(
|
||||||
|
app,
|
||||||
|
["analyze-search", "http://example.com", "--format", "table"],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Integration-style tests
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_help():
|
||||||
|
"""Test that --help works on the main app."""
|
||||||
|
result = runner.invoke(app, ["--help"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "FINN real estate analysis" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_cache_subcommand_help():
|
||||||
|
"""Test that cache subcommand has help."""
|
||||||
|
result = runner.invoke(app, ["cache", "--help"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Cache management" in result.stdout.lower() or "cache" in result.stdout.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_subcommand_help():
|
||||||
|
"""Test that config subcommand has help."""
|
||||||
|
result = runner.invoke(app, ["config", "--help"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "configuration" in result.stdout.lower() or "config" in result.stdout.lower()
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""Tests for the __main__.py entry point."""
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from finn_eiendom.__main__ import app # noqa: F401
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_entry_point():
|
||||||
|
"""Test that __main__.py can be imported and has the app entry point."""
|
||||||
|
from finn_eiendom.__main__ import app
|
||||||
|
|
||||||
|
assert app is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_via_module():
|
||||||
|
"""Test that the module can be run with python -m finn_eiendom."""
|
||||||
|
# This tests that __main__.py correctly invokes the CLI app
|
||||||
|
with patch("finn_eiendom.cli.app") as mock_app:
|
||||||
|
# Simulate running via __main__
|
||||||
|
import finn_eiendom.__main__
|
||||||
|
|
||||||
|
# The module should have imported app from cli
|
||||||
|
assert finn_eiendom.__main__.app is not None
|
||||||
@@ -17,6 +17,8 @@ def test_score_ad_and_classify():
|
|||||||
rooms=4,
|
rooms=4,
|
||||||
)
|
)
|
||||||
scores = score_ad(ad, unit, [])
|
scores = score_ad(ad, unit, [])
|
||||||
assert scores["market_position"] >= 0
|
assert "total" in scores
|
||||||
|
assert scores["total"] >= 0
|
||||||
|
assert scores["total"] <= 100
|
||||||
categories = classify_ad(scores)
|
categories = classify_ad(scores)
|
||||||
assert isinstance(categories, list)
|
assert isinstance(categories, list)
|
||||||
|
|||||||
+82
-2
@@ -4,8 +4,12 @@ from unittest.mock import patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from finn_eiendom.models import EiendomUnit, FinnAd
|
from finn_eiendom.models import EiendomUnit, FinnAd, SimilarUnit
|
||||||
from finn_eiendom.service import get_or_fetch_ad, get_or_fetch_eiendom_unit
|
from finn_eiendom.service import (
|
||||||
|
get_or_fetch_ad,
|
||||||
|
get_or_fetch_eiendom_unit,
|
||||||
|
get_or_fetch_similar_units,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -95,3 +99,79 @@ async def test_get_or_fetch_eiendom_unit_fetches_when_cache_miss():
|
|||||||
assert result.unit_code == "test-code"
|
assert result.unit_code == "test-code"
|
||||||
mock_fetch.assert_called_once_with("test-code")
|
mock_fetch.assert_called_once_with("test-code")
|
||||||
mock_save.assert_called_once()
|
mock_save.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_or_fetch_similar_units_uses_cache():
|
||||||
|
"""Test that get_or_fetch_similar_units returns cached units without fetching."""
|
||||||
|
mock_unit = EiendomUnit(unit_code="test-code")
|
||||||
|
mock_similar = [SimilarUnit(unit_code="comp1"), SimilarUnit(unit_code="comp2")]
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("finn_eiendom.service.init_db"),
|
||||||
|
patch("finn_eiendom.service.get_or_fetch_eiendom_unit", return_value=mock_unit),
|
||||||
|
patch("finn_eiendom.service.get_cached_similar_units", return_value=mock_similar) as mock_get,
|
||||||
|
patch("finn_eiendom.service.get_similar_units") as mock_fetch,
|
||||||
|
):
|
||||||
|
result = await get_or_fetch_similar_units("test-code", "RECENTLY_SOLD")
|
||||||
|
|
||||||
|
assert len(result) == 2
|
||||||
|
assert result[0].unit_code == "comp1"
|
||||||
|
mock_get.assert_called_once()
|
||||||
|
mock_fetch.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_or_fetch_similar_units_fetches_when_cache_miss():
|
||||||
|
"""Test that get_or_fetch_similar_units fetches when cache is empty."""
|
||||||
|
mock_unit = EiendomUnit(unit_code="test-code")
|
||||||
|
mock_similar = [SimilarUnit(unit_code="comp1"), SimilarUnit(unit_code="comp2")]
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("finn_eiendom.service.init_db"),
|
||||||
|
patch("finn_eiendom.service.get_or_fetch_eiendom_unit", return_value=mock_unit),
|
||||||
|
patch("finn_eiendom.service.get_cached_similar_units", return_value=[]),
|
||||||
|
patch("finn_eiendom.service.build_unit_vector", return_value="vector_data"),
|
||||||
|
patch("finn_eiendom.service.get_similar_units", return_value=mock_similar) as mock_fetch,
|
||||||
|
patch("finn_eiendom.service.save_similar_units") as mock_save,
|
||||||
|
):
|
||||||
|
result = await get_or_fetch_similar_units("test-code", "RECENTLY_SOLD")
|
||||||
|
|
||||||
|
assert len(result) == 2
|
||||||
|
assert result[0].unit_code == "comp1"
|
||||||
|
mock_fetch.assert_called_once_with("vector_data", listing_status="RECENTLY_SOLD")
|
||||||
|
mock_save.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_or_fetch_similar_units_force_refresh():
|
||||||
|
"""Test that force_refresh=True bypasses cache."""
|
||||||
|
mock_unit = EiendomUnit(unit_code="test-code")
|
||||||
|
mock_similar = [SimilarUnit(unit_code="comp1"), SimilarUnit(unit_code="comp2")]
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("finn_eiendom.service.init_db"),
|
||||||
|
patch("finn_eiendom.service.get_or_fetch_eiendom_unit", return_value=mock_unit),
|
||||||
|
patch("finn_eiendom.service.get_cached_similar_units", return_value=mock_similar) as mock_get,
|
||||||
|
patch("finn_eiendom.service.build_unit_vector", return_value="vector_data"),
|
||||||
|
patch("finn_eiendom.service.get_similar_units", return_value=mock_similar) as mock_fetch,
|
||||||
|
patch("finn_eiendom.service.save_similar_units") as mock_save,
|
||||||
|
):
|
||||||
|
result = await get_or_fetch_similar_units("test-code", "RECENTLY_SOLD", force_refresh=True)
|
||||||
|
|
||||||
|
assert len(result) == 2
|
||||||
|
mock_get.assert_not_called()
|
||||||
|
mock_fetch.assert_called_once_with("vector_data", listing_status="RECENTLY_SOLD")
|
||||||
|
mock_save.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_or_fetch_similar_units_handles_missing_unit():
|
||||||
|
"""Test that get_or_fetch_similar_units returns empty list when unit is missing."""
|
||||||
|
with (
|
||||||
|
patch("finn_eiendom.service.init_db"),
|
||||||
|
patch("finn_eiendom.service.get_or_fetch_eiendom_unit", return_value=None),
|
||||||
|
):
|
||||||
|
result = await get_or_fetch_similar_units("test-code", "RECENTLY_SOLD")
|
||||||
|
|
||||||
|
assert result == []
|
||||||
|
|||||||
Reference in New Issue
Block a user