From 2933b8c1ea3961f5108f395825946f85c1fdbf53 Mon Sep 17 00:00:00 2001 From: Ole Date: Tue, 26 May 2026 13:54:58 +0000 Subject: [PATCH] Add tests for CLI entry point and scoring functionality; enhance service layer tests for similar units Co-authored-by: Copilot --- .vscode/settings.json | 5 +- Makefile | 14 + pyproject.toml | 9 +- tests/test_cli.py | 729 ++++++++++++++++++++++++++++++++++++++++++ tests/test_main.py | 25 ++ tests/test_scoring.py | 4 +- tests/test_service.py | 84 ++++- 7 files changed, 865 insertions(+), 5 deletions(-) create mode 100644 tests/test_cli.py create mode 100644 tests/test_main.py diff --git a/.vscode/settings.json b/.vscode/settings.json index 95b5654..35056e1 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -21,6 +21,9 @@ "**/.ruff_cache": true }, "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 } } \ No newline at end of file diff --git a/Makefile b/Makefile index a1c0a36..1f554b3 100644 --- a/Makefile +++ b/Makefile @@ -22,6 +22,13 @@ test: ## Run the full test suite test-fast: ## Run tests, fail fast, verbose $(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 $(BIN)/ruff check . @@ -45,3 +52,10 @@ mcp: ## Start the MCP server over stdio doctor: ## Smoke-check the install $(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 \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 17fd2db..4bd8fab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,4 +51,11 @@ asyncio_mode = "auto" [tool.mypy] python_version = "3.12" strict = true -plugins = [] \ No newline at end of file +plugins = [] + +[tool.coverage.run] +branch = true +omit = ["*/tests/*"] + +[tool.coverage.report] +fail_under = 100 diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..51b91c8 --- /dev/null +++ b/tests/test_cli.py @@ -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() diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..e2afb5b --- /dev/null +++ b/tests/test_main.py @@ -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 diff --git a/tests/test_scoring.py b/tests/test_scoring.py index 33f2029..0e1315c 100644 --- a/tests/test_scoring.py +++ b/tests/test_scoring.py @@ -17,6 +17,8 @@ def test_score_ad_and_classify(): rooms=4, ) 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) assert isinstance(categories, list) diff --git a/tests/test_service.py b/tests/test_service.py index f6a69f7..8fe3b45 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -4,8 +4,12 @@ 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 +from finn_eiendom.models import EiendomUnit, FinnAd, SimilarUnit +from finn_eiendom.service import ( + get_or_fetch_ad, + get_or_fetch_eiendom_unit, + get_or_fetch_similar_units, +) @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" mock_fetch.assert_called_once_with("test-code") 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 == []