Files
finn-mcp/tests/test_cli.py
T
ole 55d93894ac feat(refactor): Document refactoring progress and phases in markdown
feat(scripts): Add backfill script for content_hash in cache tables

feat(scripts): Create recompute script for analysis_cache population

test(tests): Implement comprehensive tests for analysis module functions

fix(tests): Update CLI tests to assert errors on stderr instead of stdout

fix(tests): Adjust MCP integration tests to pass context parameter correctly

fix(tests): Modify service tests to return hash on save functions for consistency
2026-05-29 15:16:57 +00:00

730 lines
24 KiB
Python

"""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.stderr.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.stderr.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.stderr.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[0][1] == 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 == 0
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.mcp_server.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.stderr.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()