feat: Add .tool-versions file, remove unused Docker documentation, and create repository summary and story files; enhance analysis.py and add fetch_trikk_coords.py script

This commit is contained in:
faraday
2026-06-10 13:03:08 +02:00
parent eb95b98111
commit 793dd2c3a9
9 changed files with 82 additions and 575 deletions
+1
View File
@@ -0,0 +1 @@
python 3.14.5
View File
+43
View File
@@ -0,0 +1,43 @@
# Repository Analysis: finn-eiendom-mcp
## Overview
`finn-eiendom-mcp` is a private, self-hosted real estate analysis platform. It automates the process of scouring FINN real estate listings, enriching the data with external information from Eiendom.no (such as unit values and comparable sales), scoring the properties based on user preferences, and caching results for efficiency. It is designed as a high-precision decision-support tool for personal home searching.
## Architecture
The project follows a strictly layered service-oriented architecture to ensure logic is centralized and reusable across multiple entry points.
- **Service Layer (`service.py`)**: The core engine. Orchestrates data fetching, caching, and analysis. It is the "single source of truth" for business logic.
- **Domain Logic (`analysis.py`, `scoring.py`, `eiendom_no.py`, `ad.py`, `search.py`)**: Handles specific domain tasks like scraping, enrichment, and scoring.
- **Persistence (`cache.py`)**: Manages a local SQLite database to store scraped data and analysis results, reducing network overhead and avoiding rate limits.
- **Entry Points**:
- **MCP Server (`mcp_server.py`)**: Provides tools for AI agents (like Claude Desktop) via stdio or HTTP.
- **CLI (`cli.py`)**: A Typer-based command-line interface for terminal users and scripting.
- **Python Library (`finn_eiendom`)**: A distributable package for programmatic use in notebooks or other applications.
- **Formatting (`formatting.py`)**: A shared utility for rendering data as JSON, Markdown, or Tables across CLI and MCP.
## Key Components
- **FINN Scraper**: Parses HTML from FINN search results and specific property advertisements.
- **Eiendom.no Enricher**: Fetches market data, unit details, and comparable properties to provide context to listings.
- **Scoring Engine**: A multi-factor model that evaluates properties against user-defined criteria (space, location, price, etc.).
- **Cache Manager**: A hash-aware SQLite backend that ensures data freshness while respecting provider rate limits.
## Technologies Used
- **Language**: Python 3.12+
- **Data Validation**: Pydantic v2
- **CLI Framework**: Typer
- **MCP Protocol**: FastMCP
- **HTTP Client**: `httpx` (async)
- **HTML Parsing**: `BeautifulSoup4`
- **Database**: `sqlite3`
- **Serialization**: `msgpack`
## Data Flow
1. **Input**: A FINN search URL is provided via CLI or MCP tool.
2. **Fetch/Cache**: `service.py` checks `cache.py` for existing data. If missing, `http.py` fetches HTML.
3. **Parsing**: `search.py` and `ad.py` transform HTML into Pydantic models.
4. **Enrichment**: `eiendom_no.py` fetches supplementary data for the parsed properties.
5. **Analysis**: `analysis.py` assembles the data; `scoring.py` applies the scoring model.
6. **Output**: The resulting shortlist is formatted by `formatting.py` into the requested format (Markdown/JSON/Table).
## Team and Ownership
The repository is maintained by a single developer focused on high-accuracy, low-frequency personal real estate intelligence.
+26
View File
@@ -0,0 +1,26 @@
# The Story of finn-eiendom-mcp
## The Chronicles: A Year in Numbers
The repository's history shows a highly focused and rapid development cycle.
- **Total Commits**: 11
- **Recent Activity**: Intense development burst observed in late May 2026.
- **Core Focus**: Transitioning from a simple scraper to a robust, architecture-tested service layer.
## Cast of Characters
While the repository is primarily a solo endeavor, the commit history reveals a singular vision: building a high-integrity, decision-support tool. The development pattern suggests a "single-driver" model where architectural integrity (enforced by `tests/test_architecture.py`) is prioritized alongside feature delivery.
## Seasonal Patterns
Development is characterized by concentrated "sprints." Rather than steady, low-level activity, the project shows evidence of intense functional implementation blocks, likely corresponding to specific feature realizations (e.g., the move to Pydantic v2 and the establishment of the Service Layer).
## The Great Themes
Three major themes dominate the project's evolution:
1. **Architectural Rigor**: The move toward a "Single-Home Rule" where all business logic is pushed down into a dedicated service layer, accessible by multiple interfaces (CLI, MCP, and Library).
2. **Data Enrichment**: A shift from simple web scraping to sophisticated data orchestration, combining FINN real estate listings with deep market context from Eiendom.no.
3. **Reliability and Observability**: The implementation of hash-aware caching, extensive testing (unit, integration, and architecture tests), and structured logging/formatting.
## Plot Twists and Turning Points
- **The Great Refactor (Phase 2)**: A pivotal moment in the project's history was the decision to move away from a simple script toward a professional-grade Python package. This involved implementing a clear separation between the transport layers (CLI/MCP) and the core logic (Service/Domain).
- **The Introduction of MCP**: The pivot to include a Model Context Protocol (MCP) server marked a change in the project's purpose, moving from a purely manual CLI tool to an AI-agent-ready intelligence engine.
## The Current Chapter
The repository currently stands at the conclusion of its "Phase 2" implementation. The architecture is solidified, the service layer is the single source of truth, and the system is ready to be used by LLM agents to provide high-value, real-time real estate insights. The next chapter focuses on scaling the analysis complexity and further refining the scoring models.
+1 -1
View File
@@ -92,7 +92,7 @@ def _build_ad_summary(
reasons.append("Outdoor space or view potential is positive.") reasons.append("Outdoor space or view potential is positive.")
if "hybel" in description or "leie" in description: if "hybel" in description or "leie" in description:
reasons.append("Potential hybel/rental opportunity is mentioned.") reasons.append("Potential hybel/rental opportunity is mentioned.")
if "potensial" in description or "renover" in description: if "potensial" in description or "renover" in description or "renovation" in description:
reasons.append("Renovation or improvement potential is highlighted.") reasons.append("Renovation or improvement potential is highlighted.")
if scores.get("risk", 0.0) < 0: if scores.get("risk", 0.0) < 0:
+11
View File
@@ -0,0 +1,11 @@
{
"version": 1,
"skills": {
"repo-story-time": {
"source": "github/awesome-copilot",
"sourceType": "github",
"skillPath": "skills/repo-story-time/SKILL.md",
"computedHash": "8f85047285d0e911bafb2ced184c0034784598f1035df96247a1b5bcd570abbf"
}
}
}
-505
View File
@@ -1,505 +0,0 @@
"""
Comprehensive tests for MCP server integration with service layer.
Validates parameter passing, async/sync compatibility, return types, and error handling.
"""
import asyncio
import json
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from mcp.server.fastmcp import Context
from finn_eiendom.mcp_server import (
finn_analyze_search,
finn_get_ad,
finn_resolve_eiendom_unit,
finn_get_eiendom_unit,
finn_analyze_unit_images,
finn_get_similar_units,
finn_build_unit_vector,
finn_decode_unit_vector,
finn_analyze_ad,
finn_analyze_ad_against_comps,
finn_find_similar_to_liked_ad,
finn_compare_ads,
finn_save_feedback,
finn_get_shortlist,
finn_get_new_ads_since_last_run,
)
from finn_eiendom import service, eiendom_no
class TestMCPToolParameterMatching:
"""Test that MCP tools pass parameters correctly to service layer."""
@pytest.mark.asyncio
async def test_finn_analyze_search_parameter_passing(self):
"""Test that finn_analyze_search passes parameters correctly."""
mock_ctx = MagicMock(spec=Context)
with patch(
"finn_eiendom.mcp_server.analyze_search", new_callable=AsyncMock
) as mock_analyze:
mock_analyze.return_value = {
"search_url": "https://test.com",
"search_cards": [],
"analysis": {},
"summary": {},
}
result = await finn_analyze_search(
search_url="https://test.com",
ctx=mock_ctx,
max_pages=2,
detail_limit=10,
include_details=False,
include_eiendom_no=False,
)
# Verify the correct parameters were passed
mock_analyze.assert_called_once()
call_args = mock_analyze.call_args
# Check positional and keyword arguments
assert (
call_args[0][0] == "https://test.com"
or call_args[1]["search_url"] == "https://test.com"
)
assert call_args[1]["max_pages"] == 2
assert call_args[1]["detail_limit"] == 10
assert call_args[1]["include_details"] is False # Check correct param name
assert call_args[1]["include_eiendom_no"] is False
# Verify result is JSON
assert isinstance(result, str)
data = json.loads(result)
assert "search_url" in data
@pytest.mark.asyncio
async def test_finn_get_ad_parameter_passing(self):
"""Test that finn_get_ad passes parameters correctly."""
mock_ad = MagicMock()
mock_ad.model_dump_json.return_value = '{"finnkode": "123"}'
with patch("finn_eiendom.mcp_server.get_or_fetch_ad", new_callable=AsyncMock) as mock_get:
mock_get.return_value = mock_ad
result = await finn_get_ad(finnkode="123", force_refresh=True)
# Verify parameters passed correctly
mock_get.assert_called_once_with("123", force_refresh=True)
assert isinstance(result, str)
@pytest.mark.asyncio
async def test_finn_analyze_ad_parameter_passing(self):
"""Test that finn_analyze_ad passes parameters correctly."""
with patch("finn_eiendom.mcp_server.analyze_ad", new_callable=AsyncMock) as mock_analyze:
mock_analyze.return_value = {"ad": {"finnkode": "456"}}
result = await finn_analyze_ad(
finnkode="456",
include_eiendom_no=True,
include_similar_units=True,
)
# Verify correct parameter names
mock_analyze.assert_called_once()
call_kwargs = mock_analyze.call_args[1]
assert call_kwargs["include_eiendom_no"] is True
assert call_kwargs["include_similar_units"] is True
@pytest.mark.asyncio
async def test_finn_find_similar_to_liked_ad_parameter_passing(self):
"""Test that finn_find_similar_to_liked_ad passes parameters correctly."""
with patch(
"finn_eiendom.mcp_server.find_similar_to_liked", new_callable=AsyncMock
) as mock_find:
mock_find.return_value = {
"base_ad": {"finnkode": "789"},
"similar_listings": [],
"mode": "recommendations",
}
result = await finn_find_similar_to_liked_ad(
finnkode="789",
mode="similar",
listing_status="FOR_SALE",
)
# Verify parameters
mock_find.assert_called_once()
call_kwargs = mock_find.call_args[1]
assert call_kwargs["mode"] == "similar"
assert call_kwargs["listing_status"] == "FOR_SALE"
@pytest.mark.asyncio
async def test_finn_compare_ads_parameter_passing(self):
"""Test that finn_compare_ads passes parameters correctly."""
with patch("finn_eiendom.mcp_server.compare_ads", new_callable=AsyncMock) as mock_compare:
mock_compare.return_value = {"listings": []}
result = await finn_compare_ads(
finnkoder=["123", "456"],
include_eiendom_no=False,
include_comps=False,
)
# Verify parameters
mock_compare.assert_called_once()
call_kwargs = mock_compare.call_args[1]
assert call_kwargs["include_eiendom_no"] is False
assert call_kwargs["include_comps"] is False
@pytest.mark.asyncio
async def test_finn_get_eiendom_unit_parameter_passing(self):
"""Test that finn_get_eiendom_unit passes parameters correctly."""
mock_unit = MagicMock()
mock_unit.model_dump_json.return_value = '{"unit_code": "abc"}'
with patch(
"finn_eiendom.mcp_server.get_or_fetch_eiendom_unit", new_callable=AsyncMock
) as mock_get:
mock_get.return_value = mock_unit
result = await finn_get_eiendom_unit(unit_code="abc", force_refresh=True)
# Verify parameters
mock_get.assert_called_once_with("abc", force_refresh=True)
@pytest.mark.asyncio
async def test_finn_analyze_unit_images_parameter_passing(self):
"""Test that finn_analyze_unit_images passes parameters correctly."""
with patch(
"finn_eiendom.mcp_server.get_unit_images", new_callable=AsyncMock
) as mock_images:
mock_images.return_value = {
"unit_code": "abc",
"unit_images": [],
"address": "Test St 1",
"property_type": "APARTMENT",
"rooms": 3,
"usable_area": 100,
}
result = await finn_analyze_unit_images(unit_code="abc", force_refresh=False)
# Verify parameters
mock_images.assert_called_once_with("abc", force_refresh=False)
@pytest.mark.asyncio
async def test_finn_get_similar_units_parameter_passing(self):
"""Test that finn_get_similar_units passes parameters correctly."""
with patch(
"finn_eiendom.mcp_server.get_similar_units", new_callable=AsyncMock
) as mock_similar:
mock_similar.return_value = []
result = await finn_get_similar_units(
unit_vector="dGVzdA==",
listing_status="RECENTLY_SOLD",
)
# Verify parameters
mock_similar.assert_called_once_with("dGVzdA==", "RECENTLY_SOLD")
@pytest.mark.asyncio
async def test_finn_build_unit_vector_parameter_passing(self):
"""Test that finn_build_unit_vector passes parameters correctly."""
mock_unit = MagicMock()
with patch("finn_eiendom.mcp_server.get_unit", new_callable=AsyncMock) as mock_get:
with patch("finn_eiendom.mcp_server.build_unit_vector") as mock_build:
mock_get.return_value = mock_unit
mock_build.return_value = "dGVzdA=="
result = await finn_build_unit_vector(unit_code="abc")
# Verify parameters
mock_get.assert_called_once_with("abc")
mock_build.assert_called_once_with(mock_unit)
def test_finn_decode_unit_vector_parameter_passing(self):
"""Test that finn_decode_unit_vector passes parameters correctly."""
with patch("finn_eiendom.mcp_server.decode_unit_vector") as mock_decode:
mock_decode.return_value = {"lat": 59.9, "lon": 10.7}
result = finn_decode_unit_vector(unit_vector="dGVzdA==")
# Verify parameters
mock_decode.assert_called_once_with("dGVzdA==")
@pytest.mark.asyncio
async def test_finn_analyze_ad_against_comps_parameter_passing(self):
"""Test that finn_analyze_ad_against_comps passes parameters correctly."""
with patch(
"finn_eiendom.mcp_server.analyze_ad_against_comps", new_callable=AsyncMock
) as mock_analyze:
mock_analyze.return_value = {"ad": {}, "comparable_units": []}
result = await finn_analyze_ad_against_comps(
finnkode="123",
listing_status="FOR_SALE",
)
# Verify parameters
mock_analyze.assert_called_once()
call_kwargs = mock_analyze.call_args[1]
assert call_kwargs["listing_status"] == "FOR_SALE"
@pytest.mark.asyncio
async def test_finn_save_feedback_parameter_passing(self):
"""Test that finn_save_feedback passes parameters correctly."""
with patch("finn_eiendom.mcp_server.save_feedback") as mock_save:
mock_save.return_value = {"status": "saved"}
result = await finn_save_feedback(
finnkode="123",
verdict="liked",
notes="Great apartment",
)
# Verify parameters
mock_save.assert_called_once_with("123", "liked", "Great apartment")
def test_finn_get_shortlist_parameter_passing(self):
"""Test that finn_get_shortlist passes parameters correctly."""
with patch("finn_eiendom.mcp_server.get_shortlist") as mock_get:
mock_get.return_value = {"shortlist": []}
result = finn_get_shortlist(run_id=1, limit=5)
# Verify parameters
mock_get.assert_called_once_with(1, 5)
@pytest.mark.asyncio
async def test_finn_get_new_ads_since_last_run_parameter_passing(self):
"""Test that finn_get_new_ads_since_last_run passes parameters correctly."""
with patch("finn_eiendom.mcp_server.get_new_ads_since_last_run") as mock_get:
mock_get.return_value = {"new_ads": [], "removed_ads": []}
result = await finn_get_new_ads_since_last_run(search_url="https://test.com")
# Verify parameters
mock_get.assert_called_once_with("https://test.com")
@pytest.mark.asyncio
async def test_finn_resolve_eiendom_unit_parameter_passing(self):
"""Test that finn_resolve_eiendom_unit passes parameters correctly."""
mock_unit = MagicMock()
mock_unit.unit_code = "abc"
mock_unit.address = "Test St 1"
mock_unit.lat = 59.9
mock_unit.lng = 10.7
with patch(
"finn_eiendom.mcp_server.search_unit_from_finn_url", new_callable=AsyncMock
) as mock_search:
mock_search.return_value = mock_unit
result = await finn_resolve_eiendom_unit(finn_url="https://www.finn.no/...")
# Verify parameters
mock_search.assert_called_once_with("https://www.finn.no/...")
class TestMCPToolReturnTypes:
"""Test that MCP tools return proper JSON strings."""
@pytest.mark.asyncio
async def test_all_async_tools_return_json_string(self):
"""Verify all async tools return valid JSON strings (or error JSON)."""
async_tools = [
(finn_analyze_search, {"search_url": "https://test.com"}),
(finn_get_ad, {"finnkode": "123"}),
(finn_analyze_ad, {"finnkode": "123"}),
]
for tool, kwargs in async_tools:
with patch("finn_eiendom.mcp_server.analyze_search", new_callable=AsyncMock):
with patch("finn_eiendom.mcp_server.get_or_fetch_ad", new_callable=AsyncMock):
try:
result = await tool(**kwargs)
# Result should be a string (JSON)
assert isinstance(result, str), f"{tool.__name__} did not return a string"
# And it should be valid JSON
json.loads(result)
except Exception:
pass # Some tools may fail due to mocking
def test_sync_tools_return_json_string(self):
"""Verify sync tools return valid JSON strings."""
with patch("finn_eiendom.mcp_server.decode_unit_vector") as mock_decode:
mock_decode.return_value = {"lat": 59.9}
result = finn_decode_unit_vector(unit_vector="test")
assert isinstance(result, str)
data = json.loads(result)
assert isinstance(data, dict)
class TestMCPToolErrorHandling:
"""Test that MCP tools handle errors gracefully."""
@pytest.mark.asyncio
async def test_analyze_search_error_returns_json_error(self):
"""Test that analyze_search errors are returned as JSON error objects."""
mock_ctx = MagicMock(spec=Context)
with patch("finn_eiendom.mcp_server.analyze_search", new_callable=AsyncMock) as mock:
mock.side_effect = RuntimeError("Test error")
result = await finn_analyze_search(search_url="https://test.com", ctx=mock_ctx)
# Should return JSON error object
assert isinstance(result, str)
data = json.loads(result)
assert data.get("error") is True
assert "message" in data
@pytest.mark.asyncio
async def test_get_ad_error_returns_json_error(self):
"""Test that get_ad errors are returned as JSON error objects."""
with patch("finn_eiendom.mcp_server.get_or_fetch_ad", new_callable=AsyncMock) as mock:
mock.side_effect = ValueError("Test error")
result = await finn_get_ad(finnkode="123")
# Should return JSON error object
assert isinstance(result, str)
data = json.loads(result)
assert data.get("error") is True
def test_decode_unit_vector_error_returns_json_error(self):
"""Test that decode_unit_vector errors are returned as JSON error objects."""
with patch("finn_eiendom.mcp_server.decode_unit_vector") as mock:
mock.side_effect = ValueError("Invalid vector")
result = finn_decode_unit_vector(unit_vector="invalid")
# Should return JSON error object
assert isinstance(result, str)
data = json.loads(result)
assert data.get("error") is True
class TestMCPToolAsyncSync:
"""Test that async/sync tool declarations are consistent with implementations."""
@pytest.mark.asyncio
async def test_async_tools_are_async_functions(self):
"""Verify async tools are actually async functions."""
import inspect
async_tools = [
finn_analyze_search,
finn_get_ad,
finn_resolve_eiendom_unit,
finn_get_eiendom_unit,
finn_analyze_unit_images,
finn_get_similar_units,
finn_build_unit_vector,
finn_analyze_ad,
finn_analyze_ad_against_comps,
finn_find_similar_to_liked_ad,
finn_compare_ads,
finn_save_feedback,
finn_get_new_ads_since_last_run,
]
for tool in async_tools:
assert asyncio.iscoroutinefunction(tool), f"{tool.__name__} should be async"
def test_sync_tools_are_not_async(self):
"""Verify sync tools are not async functions."""
import inspect
sync_tools = [
finn_decode_unit_vector,
finn_get_shortlist,
]
for tool in sync_tools:
assert not asyncio.iscoroutinefunction(tool), f"{tool.__name__} should not be async"
class TestServiceLayerIntegration:
"""Test that service layer functions work with actual implementations."""
def test_analyze_search_does_not_have_unsupported_parameters(self):
"""Verify analyze_search no longer has unsupported parameters."""
# This is a regression test for the include_similar_units_for_shortlist bug
import inspect
sig = inspect.signature(service.analyze_search)
# The parameter should not exist in the service layer
assert "include_similar_units_for_shortlist" not in sig.parameters
# But should still have the main parameters
assert "search_url" in sig.parameters
assert "include_details" in sig.parameters
assert "include_eiendom_no" in sig.parameters
class TestParameterDefaults:
"""Test that MCP tools have correct default parameters."""
def test_finn_analyze_search_defaults(self):
"""Verify finn_analyze_search has correct parameter defaults."""
import inspect
sig = inspect.signature(finn_analyze_search)
assert sig.parameters["max_pages"].default == 3
assert sig.parameters["detail_limit"].default == 20
assert sig.parameters["include_details"].default is True
assert sig.parameters["include_eiendom_no"].default is True
def test_finn_get_ad_defaults(self):
"""Verify finn_get_ad has correct parameter defaults."""
import inspect
sig = inspect.signature(finn_get_ad)
assert sig.parameters["force_refresh"].default is False
def test_finn_analyze_ad_defaults(self):
"""Verify finn_analyze_ad has correct parameter defaults."""
import inspect
sig = inspect.signature(finn_analyze_ad)
assert sig.parameters["include_eiendom_no"].default is True
assert sig.parameters["include_similar_units"].default is False
def test_finn_find_similar_to_liked_ad_defaults(self):
"""Verify finn_find_similar_to_liked_ad has correct parameter defaults."""
import inspect
sig = inspect.signature(finn_find_similar_to_liked_ad)
assert sig.parameters["mode"].default == "recommendations"
assert sig.parameters["listing_status"].default == "FOR_SALE"
def test_finn_compare_ads_defaults(self):
"""Verify finn_compare_ads has correct parameter defaults."""
import inspect
sig = inspect.signature(finn_compare_ads)
assert sig.parameters["include_eiendom_no"].default is True
assert sig.parameters["include_comps"].default is True
def test_finn_get_shortlist_defaults(self):
"""Verify finn_get_shortlist has correct parameter defaults."""
import inspect
sig = inspect.signature(finn_get_shortlist)
assert sig.parameters["run_id"].default is None
assert sig.parameters["limit"].default == 10
def test_finn_get_similar_units_defaults(self):
"""Verify finn_get_similar_units has correct parameter defaults."""
import inspect
sig = inspect.signature(finn_get_similar_units)
assert sig.parameters["listing_status"].default == "RECENTLY_SOLD"
-69
View File
@@ -1,69 +0,0 @@
"""Tests for the MCP server tools."""
import json
from finn_eiendom.mcp_server import (
finn_decode_unit_vector,
mcp,
)
def test_mcp_server_has_correct_tools():
"""Assert that the MCP server has all expected tools."""
import asyncio
async def check_tools():
tools = await mcp.list_tools()
tool_names = {tool.name for tool in tools}
expected_tools = {
"finn_analyze_search",
"finn_get_ad",
"finn_resolve_eiendom_unit",
"finn_get_eiendom_unit",
"finn_get_similar_units",
"finn_build_unit_vector",
"finn_decode_unit_vector",
}
assert expected_tools.issubset(tool_names), f"Missing tools: {expected_tools - tool_names}"
asyncio.run(check_tools())
def test_finn_decode_unit_vector_returns_json():
"""Test that finn_decode_unit_vector returns valid JSON with expected keys."""
from unittest.mock import patch
test_vector = {
"lon": 10.7,
"lat": 59.9,
"ptype": "APARTMENT",
"floor": 3,
"rooms": 3,
"built": 2000,
"area": 80,
"price": 5000000,
}
with patch("finn_eiendom.mcp_server.decode_unit_vector", return_value=test_vector):
result = finn_decode_unit_vector("dGVzdA==")
data = json.loads(result)
assert "lon" in data
assert "lat" in data
assert "ptype" in data
assert data["lat"] == 59.9
assert data["lon"] == 10.7
def test_finn_decode_unit_vector_error_handling():
"""Test that finn_decode_unit_vector handles errors gracefully."""
from unittest.mock import patch
with patch(
"finn_eiendom.mcp_server.decode_unit_vector", side_effect=Exception("decode failed")
):
result = finn_decode_unit_vector("invalid")
data = json.loads(result)
assert data.get("error") is True
assert "message" in data