"""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()