Merge pull request #1056 from Hestia-Homes/feature/magicplan-api-client

Magicplan API client and Service class
This commit is contained in:
Daniel Roth 2026-05-08 11:37:52 +01:00 committed by GitHub
commit 1105880b1b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 1352 additions and 27 deletions

View file

@ -45,6 +45,8 @@ class Settings(BaseSettings):
# Third parties # Third parties
EPC_AUTH_TOKEN: str = "changeme" EPC_AUTH_TOKEN: str = "changeme"
GOOGLE_SOLAR_API_KEY: str = "changeme" GOOGLE_SOLAR_API_KEY: str = "changeme"
MAGICPLAN_CUSTOMER_ID: str = "changeme"
MAGICPLAN_API_KEY: str = "changeme"
# Database settings # Database settings
DB_HOST: str = "changeme" DB_HOST: str = "changeme"

View file

@ -0,0 +1,141 @@
from typing import Any, cast
from sqlalchemy import delete, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlmodel import Session, col
from datatypes.magicplan.domain.models import Floor, Plan
from backend.app.db.models.magic_plan import (
MagicPlanDoorModel,
MagicPlanFloorModel,
MagicPlanPlanModel,
MagicPlanRoomModel,
MagicPlanWindowModel,
)
def save_plan(session: Session, plan: Plan) -> None:
plan_id: int = _upsert_plan(session, plan)
_delete_children(session, plan_id)
floor_ids: list[int] = _insert_floors(session, plan.floors, plan_id)
room_ids: list[int] = _insert_rooms(session, plan.floors, floor_ids)
_insert_windows_and_doors(session, plan.floors, room_ids)
def _upsert_plan(session: Session, plan: Plan) -> int:
stmt = (
pg_insert(MagicPlanPlanModel)
.values(
magic_plan_uid=plan.uid,
name=plan.name,
address=plan.address,
postcode=plan.postcode,
)
.on_conflict_do_update(
index_elements=["magic_plan_uid"],
set_={
"name": plan.name,
"address": plan.address,
"postcode": plan.postcode,
},
)
.returning(col(MagicPlanPlanModel.id))
)
row_id: int = session.execute(stmt).scalar_one()
return row_id
def _delete_children(session: Session, plan_id: int) -> None:
floor_subq = (
select(col(MagicPlanFloorModel.id))
.where(col(MagicPlanFloorModel.magic_plan_plan_id) == plan_id)
.scalar_subquery()
)
room_subq = (
select(col(MagicPlanRoomModel.id))
.where(col(MagicPlanRoomModel.magic_plan_floor_id).in_(floor_subq))
.scalar_subquery()
)
session.execute(
delete(MagicPlanWindowModel).where(
col(MagicPlanWindowModel.magic_plan_room_id).in_(room_subq)
)
)
session.execute(
delete(MagicPlanDoorModel).where(
col(MagicPlanDoorModel.magic_plan_room_id).in_(room_subq)
)
)
session.execute(
delete(MagicPlanRoomModel).where(
col(MagicPlanRoomModel.magic_plan_floor_id).in_(floor_subq)
)
)
session.execute(
delete(MagicPlanFloorModel).where(
col(MagicPlanFloorModel.magic_plan_plan_id) == plan_id
)
)
def _insert_floors(session: Session, floors: list[Floor], plan_id: int) -> list[int]:
rows: list[dict[str, Any]] = [
{"magic_plan_plan_id": plan_id, "level": floor.level} for floor in floors
]
result = session.execute(
pg_insert(MagicPlanFloorModel)
.values(rows)
.returning(col(MagicPlanFloorModel.id))
)
return cast(list[int], list(result.scalars().all()))
def _insert_rooms(
session: Session, floors: list[Floor], floor_ids: list[int]
) -> list[int]:
rows: list[dict[str, Any]] = [
{
"magic_plan_floor_id": floor_id,
"name": room.name,
"width_m": room.width_m,
"length_m": room.length_m,
"area_m2": room.area_m2,
}
for floor, floor_id in zip(floors, floor_ids)
for room in floor.rooms
]
result = session.execute(
pg_insert(MagicPlanRoomModel).values(rows).returning(col(MagicPlanRoomModel.id))
)
return cast(list[int], list(result.scalars().all()))
def _insert_windows_and_doors(
session: Session, floors: list[Floor], room_ids: list[int]
) -> None:
all_rooms = [room for floor in floors for room in floor.rooms]
window_rows: list[dict[str, Any]] = [
{
"magic_plan_room_id": room_id,
"width_m": window.width_m,
"height_m": window.height_m,
"area_m2": window.area_m2,
"opening_type": window.opening_type,
}
for room, room_id in zip(all_rooms, room_ids)
for window in room.windows
]
door_rows: list[dict[str, Any]] = [
{
"magic_plan_room_id": room_id,
"width_mm": door.width_mm,
}
for room, room_id in zip(all_rooms, room_ids)
for door in room.doors
]
if window_rows:
session.execute(pg_insert(MagicPlanWindowModel).values(window_rows))
if door_rows:
session.execute(pg_insert(MagicPlanDoorModel).values(door_rows))

View file

@ -0,0 +1,41 @@
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlmodel import SQLModel
import backend.app.db.models.magic_plan # noqa: F401 — registers MagicPlan models with SQLModel.metadata
# TODO: promote to backend/app/db/conftest.py once a second DB-touching test directory appears under this tree
@pytest.fixture(scope="function")
def engine(postgresql):
connection_string = (
f"postgresql+psycopg://"
f"{postgresql.info.user}:"
f"{postgresql.info.password}@"
f"{postgresql.info.host}:"
f"{postgresql.info.port}/"
f"{postgresql.info.dbname}"
)
engine = create_engine(connection_string)
SQLModel.metadata.create_all(engine)
yield engine
SQLModel.metadata.drop_all(engine)
engine.dispose()
@pytest.fixture(scope="function")
def db_session(engine):
connection = engine.connect()
transaction = connection.begin()
session = sessionmaker(bind=connection)()
yield session
session.close()
transaction.rollback()
connection.close()

View file

@ -0,0 +1,95 @@
import json
from pathlib import Path
import pytest
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from sqlmodel import SQLModel
from datatypes.magicplan.api.response import MagicPlanPlan
from datatypes.magicplan.domain.mapper import map_plan
from datatypes.magicplan.domain.models import Plan
from backend.app.db.functions.magic_plan_functions import save_plan
from backend.app.db.models.magic_plan import (
MagicPlanDoorModel,
MagicPlanFloorModel,
MagicPlanPlanModel,
MagicPlanRoomModel,
MagicPlanWindowModel,
)
FIXTURE_DIR = Path(__file__).parents[4] / "magic_plan"
@pytest.fixture(scope="module")
def domain_plan() -> Plan:
data = json.loads(
(FIXTURE_DIR / "magicplan_api_plan_response_example.json").read_text()
)
return map_plan(MagicPlanPlan.model_validate(data["data"]))
def _count(session: Session, model: type[SQLModel]) -> int:
return session.execute(select(func.count()).select_from(model)).scalar_one()
def test_plan_row_present_after_save(db_session: Session, domain_plan: Plan) -> None:
# Act
save_plan(db_session, domain_plan)
# Assert
assert _count(db_session, MagicPlanPlanModel) == 1
def test_floor_count_matches_domain(db_session: Session, domain_plan: Plan) -> None:
# Arrange
expected = len(domain_plan.floors)
# Act
save_plan(db_session, domain_plan)
# Assert
assert _count(db_session, MagicPlanFloorModel) == expected
def test_room_count_matches_domain(db_session: Session, domain_plan: Plan) -> None:
# Arrange
expected = sum(len(f.rooms) for f in domain_plan.floors)
# Act
save_plan(db_session, domain_plan)
# Assert
assert _count(db_session, MagicPlanRoomModel) == expected
def test_window_count_matches_domain(db_session: Session, domain_plan: Plan) -> None:
# Arrange
expected = sum(len(r.windows) for f in domain_plan.floors for r in f.rooms)
# Act
save_plan(db_session, domain_plan)
# Assert
assert _count(db_session, MagicPlanWindowModel) == expected
def test_door_count_matches_domain(db_session: Session, domain_plan: Plan) -> None:
# Arrange
expected = sum(len(r.doors) for f in domain_plan.floors for r in f.rooms)
# Act
save_plan(db_session, domain_plan)
# Assert
assert _count(db_session, MagicPlanDoorModel) == expected
def test_save_plan_idempotent(db_session: Session, domain_plan: Plan) -> None:
# Act — call twice within the same session
save_plan(db_session, domain_plan)
save_plan(db_session, domain_plan)
# Assert — same row counts as a single call
assert _count(db_session, MagicPlanPlanModel) == 1
assert _count(db_session, MagicPlanFloorModel) == len(domain_plan.floors)
assert _count(db_session, MagicPlanRoomModel) == sum(
len(f.rooms) for f in domain_plan.floors
)
assert _count(db_session, MagicPlanWindowModel) == sum(
len(r.windows) for f in domain_plan.floors for r in f.rooms
)
assert _count(db_session, MagicPlanDoorModel) == sum(
len(r.doors) for f in domain_plan.floors for r in f.rooms
)

View file

@ -0,0 +1,52 @@
from typing import Optional
from sqlmodel import Field, SQLModel
class MagicPlanPlanModel(SQLModel, table=True):
__tablename__ = "magic_plan_plan"
id: Optional[int] = Field(default=None, primary_key=True)
magic_plan_uid: Optional[str] = Field(default=None, unique=True, index=True)
name: Optional[str] = None
address: Optional[str] = None
postcode: Optional[str] = None
class MagicPlanFloorModel(SQLModel, table=True):
__tablename__ = "magic_plan_floor"
id: Optional[int] = Field(default=None, primary_key=True)
magic_plan_plan_id: int = Field(foreign_key="magic_plan_plan.id")
level: Optional[int] = None
class MagicPlanRoomModel(SQLModel, table=True):
__tablename__ = "magic_plan_room"
id: Optional[int] = Field(default=None, primary_key=True)
magic_plan_floor_id: int = Field(foreign_key="magic_plan_floor.id")
name: Optional[str] = None
width_m: Optional[float] = None
length_m: Optional[float] = None
area_m2: Optional[float] = None
class MagicPlanWindowModel(SQLModel, table=True):
__tablename__ = "magic_plan_window"
id: Optional[int] = Field(default=None, primary_key=True)
magic_plan_room_id: int = Field(foreign_key="magic_plan_room.id")
width_m: Optional[float] = None
height_m: Optional[float] = None
area_m2: Optional[float] = None
opening_type: Optional[str] = None
class MagicPlanDoorModel(SQLModel, table=True):
__tablename__ = "magic_plan_door"
id: Optional[int] = Field(default=None, primary_key=True)
magic_plan_room_id: int = Field(foreign_key="magic_plan_room.id")
width_mm: Optional[float] = None
type: Optional[str] = None

View file

@ -0,0 +1,134 @@
from backend.app.db.models.magic_plan import (
MagicPlanDoorModel,
MagicPlanFloorModel,
MagicPlanPlanModel,
MagicPlanRoomModel,
MagicPlanWindowModel,
)
# --- MagicPlanPlan ---
def test_plan_table_name() -> None:
assert MagicPlanPlanModel.__tablename__ == "magic_plan_plan"
def test_plan_has_magic_plan_uid_column() -> None:
assert "magic_plan_uid" in MagicPlanPlanModel.__table__.columns
def test_plan_magic_plan_uid_is_unique() -> None:
col = MagicPlanPlanModel.__table__.columns["magic_plan_uid"]
assert (
any(
c.unique
for c in MagicPlanPlanModel.__table__.constraints
if hasattr(c, "columns")
and "magic_plan_uid" in [cc.name for cc in c.columns]
)
or col.unique
)
def test_plan_instantiation() -> None:
plan = MagicPlanPlanModel(
magic_plan_uid="uid-123", name="Test", address="1 High St", postcode="SW1A 1AA"
)
assert plan.magic_plan_uid == "uid-123"
assert plan.name == "Test"
assert plan.postcode == "SW1A 1AA"
# --- MagicPlanFloor ---
def test_floor_table_name() -> None:
assert MagicPlanFloorModel.__tablename__ == "magic_plan_floor"
def test_floor_fk_column_name() -> None:
assert "magic_plan_plan_id" in MagicPlanFloorModel.__table__.columns
def test_floor_has_level() -> None:
floor = MagicPlanFloorModel(magic_plan_plan_id=1, level=0)
assert floor.level == 0
# --- MagicPlanRoom ---
def test_room_table_name() -> None:
assert MagicPlanRoomModel.__tablename__ == "magic_plan_room"
def test_room_fk_column_name() -> None:
assert "magic_plan_floor_id" in MagicPlanRoomModel.__table__.columns
def test_room_has_measurement_columns() -> None:
cols = MagicPlanRoomModel.__table__.columns
assert "width_m" in cols
assert "length_m" in cols
assert "area_m2" in cols
def test_room_instantiation() -> None:
room = MagicPlanRoomModel(
magic_plan_floor_id=1, name="Kitchen", width_m=2.67, length_m=2.98, area_m2=7.95
)
assert room.name == "Kitchen"
assert room.width_m == 2.67
# --- MagicPlanWindow ---
def test_window_table_name() -> None:
assert MagicPlanWindowModel.__tablename__ == "magic_plan_window"
def test_window_fk_column_name() -> None:
assert "magic_plan_room_id" in MagicPlanWindowModel.__table__.columns
def test_window_has_measurement_columns() -> None:
cols = MagicPlanWindowModel.__table__.columns
assert "width_m" in cols
assert "height_m" in cols
assert "area_m2" in cols
assert "opening_type" in cols
def test_window_instantiation() -> None:
window = MagicPlanWindowModel(
magic_plan_room_id=1,
width_m=1.4,
height_m=1.2,
area_m2=1.68,
opening_type="casement",
)
assert window.opening_type == "casement"
# --- MagicPlanDoor ---
def test_door_table_name() -> None:
assert MagicPlanDoorModel.__tablename__ == "magic_plan_door"
def test_door_fk_column_name() -> None:
assert "magic_plan_room_id" in MagicPlanDoorModel.__table__.columns
def test_door_has_width_mm_and_type() -> None:
cols = MagicPlanDoorModel.__table__.columns
assert "width_mm" in cols
assert "type" in cols
def test_door_instantiation() -> None:
door = MagicPlanDoorModel(magic_plan_room_id=1, width_mm=0.79, type="hinged")
assert door.width_mm == 0.79
assert door.type == "hinged"

View file

View file

@ -0,0 +1,46 @@
import re
from typing import Optional
from datatypes.magicplan.api.response import PlanSummary
_UK_POSTCODE_RE = re.compile(r"[A-Z]{1,2}\d[A-Z\d]?\s*\d[A-Z]{2}", re.IGNORECASE)
def _extract_postcode(address: str) -> str | None:
match = _UK_POSTCODE_RE.search(address)
if match is None:
return None
return match.group().replace(" ", "").upper()
def _normalize_postcode(postcode: str) -> str:
return postcode.replace(" ", "").upper()
def find_matching_plan(plans: list[PlanSummary], address: str) -> Optional[PlanSummary]:
postcode = _extract_postcode(address)
if postcode is None:
return None
address_lower = address.lower()
for plan in plans:
if plan.address is None:
continue
plan_postcode = plan.address.postal_code
if plan_postcode is None:
continue
if _normalize_postcode(plan_postcode) != postcode:
continue
street_parts = [
p for p in [plan.address.street_number, plan.address.street] if p
]
plan_street = " ".join(street_parts).lower()
if plan_street and plan_street in address_lower:
return plan
return None

View file

@ -0,0 +1,36 @@
from typing import Any
from backend.app.config import get_settings
from backend.magic_plan.magic_plan_client import MagicPlanClient
from backend.magic_plan.magic_plan_service import MagicPlanService
from backend.magic_plan.magic_plan_trigger_request import MagicPlanTriggerRequest
from datatypes.magicplan.domain.models import Plan
from backend.utils.subtasks import task_handler
from utils.logger import setup_logger
logger = setup_logger()
@task_handler()
def handler(body: dict[str, Any], context: Any) -> str:
settings = get_settings()
payload = MagicPlanTriggerRequest.model_validate(body)
client = MagicPlanClient(
customer_id=settings.MAGICPLAN_CUSTOMER_ID,
api_key=settings.MAGICPLAN_API_KEY,
)
plan: Plan = MagicPlanService(client).run(payload.address, payload.uprn)
logger.info("Saved MagicPlan plan uid=%s", plan.uid)
return plan.uid
if __name__ == "__main__":
event = {
"Records": [
{
"body": '{"address": "2 Laburnum Way Bromley BR2 8BZ"}',
"messageId": "local-test",
}
]
}
handler(event, None)

View file

@ -0,0 +1,24 @@
import requests
from datatypes.magicplan.api.response import MagicPlanPlan, PlansListResponse
_BASE_URL = "https://cloud.magicplan.app/api/v2"
class MagicPlanClient:
def __init__(self, customer_id: str, api_key: str) -> None:
self._api_key = api_key
self._session = requests.Session()
self._session.headers.update({"customer": customer_id})
def get_plans(self) -> PlansListResponse:
r = self._session.get(f"{_BASE_URL}/plans", params={"key": self._api_key})
r.raise_for_status()
return PlansListResponse.model_validate(r.json()["data"])
def get_plan(self, plan_id: str) -> MagicPlanPlan:
r = self._session.get(
f"{_BASE_URL}/plans/{plan_id}", params={"key": self._api_key}
)
r.raise_for_status()
return MagicPlanPlan.model_validate(r.json()["data"])

View file

@ -0,0 +1,42 @@
from typing import Optional
from datatypes.magicplan.api.response import (
MagicPlanPlan,
PlanSummary,
PlansListResponse,
)
from datatypes.magicplan.domain.mapper import map_plan
from datatypes.magicplan.domain.models import Plan
from backend.app.db.connection import db_session
from backend.app.db.functions.magic_plan_functions import save_plan
from backend.magic_plan.address_matcher import find_matching_plan
from backend.magic_plan.magic_plan_client import MagicPlanClient
from utils.logger import setup_logger
logger = setup_logger()
class MagicPlanService:
def __init__(self, client: MagicPlanClient) -> None:
self._client = client
def run(self, address: str, uprn: Optional[str] = None) -> Plan:
if uprn is not None:
logger.info("MagicPlanService.run uprn=%s", uprn)
plans_response: PlansListResponse = self._client.get_plans()
matched: Optional[PlanSummary] = find_matching_plan(
plans_response.plans, address
) # TODO: use address2UPRN instead? or create AddressMatch domain class
if matched is None:
raise ValueError(f"No MagicPlan found for address: {address!r}")
magic_plan: MagicPlanPlan = self._client.get_plan(matched.id)
plan: Plan = map_plan(magic_plan)
with db_session() as session:
save_plan(session, plan)
return plan

View file

@ -0,0 +1,10 @@
from typing import Optional
from pydantic import BaseModel, ConfigDict
class MagicPlanTriggerRequest(BaseModel):
model_config = ConfigDict(extra="ignore")
address: str
uprn: Optional[str] = None

View file

@ -0,0 +1,39 @@
{
"data": {
"paging": {
"page": 1,
"next_page": false,
"count": 1
},
"plans": [
{
"id": "9f9889ff-793e-4e9a-a6f0-e22f5b0f5365",
"project_id": "269422e7-45b6-4582-b124-405053dcd967",
"name": "11, Br1 3lp",
"address": {
"street": "11 Station Road",
"street_number": null,
"postal_code": "BR1 3LP",
"city": "Bromley",
"country": "GB",
"longitude": 0.01593668,
"latitude": 51.40901033
},
"creation_date": "2026-04-28T09:35:44+00:00",
"update_date": "2026-05-05T12:53:36+00:00",
"thumbnail_url": "https://s3.amazonaws.com/prod.plans.sensopia.com/9f9889ff-793e-4e9a-a6f0-e22f5b0f5365/plan.thumb",
"public_url": "https://cloud.magicplan.app/plan/9f9889ff-793e-4e9a-a6f0-e22f5b0f5365",
"cloud_url": "https://cloud.magicplan.app/projects/9f9889ff-793e-4e9a-a6f0-e22f5b0f5365",
"3d_url": "https://3d.magicplan.app/#embed/?key=MmFkZDJjNGRmYWRjM2Y5ZDAwMjEyZGRlY2I3NmJjOWFjOWRmMDdkNzIxZTViZDdhNTgxZDBiYWE1YTYzZTJmY%2FJNEogVfW%2FZwVfY25qc24oCKnfVxiF%2FupeeA7vwS8FECF0L9E7DUFE%2ByzEYzYaoVc%2FbtsZ%2FqZOSPopiR4OqD3zbCziU0QTydELS32cnSFOT",
"workgroup_id": "677d01685458a",
"team_id": null,
"created_by": {
"id": "b19771e9-1aad-45a5-9a41-f01a835172ea",
"firstname": null,
"lastname": null,
"email": "archie.ratcliff@domna.homes"
}
}
]
}
}

View file

View file

@ -0,0 +1,129 @@
from datatypes.magicplan.api.response import PlanSummary
from backend.magic_plan.address_matcher import find_matching_plan, _extract_postcode
def _make_plan(
plan_id: str,
street: str | None = None,
street_number: str | None = None,
postal_code: str | None = None,
) -> PlanSummary:
return PlanSummary.model_validate(
{
"id": plan_id,
"name": f"Plan {plan_id}",
"address": {
"street": street,
"street_number": street_number,
"postal_code": postal_code,
},
}
)
# --- _extract_postcode ---
def test_extract_postcode_standard_format() -> None:
assert _extract_postcode("2 Laburnum Way Bromley BR2 8BZ") == "BR28BZ"
def test_extract_postcode_no_space_in_postcode() -> None:
assert _extract_postcode("123 High St London SW1A1AA") == "SW1A1AA"
def test_extract_postcode_lowercase_input() -> None:
assert _extract_postcode("2 laburnum way br2 8bz") == "BR28BZ"
def test_extract_postcode_none_when_absent() -> None:
assert _extract_postcode("123 High Street London") is None
def test_extract_postcode_none_for_empty_string() -> None:
assert _extract_postcode("") is None
# --- find_matching_plan ---
PLAN_A = _make_plan(
"plan-a", street="Laburnum Way", street_number="2", postal_code="BR2 8BZ"
)
PLAN_B = _make_plan(
"plan-b", street="Station Road", street_number="11", postal_code="BR1 3LP"
)
def test_find_matching_plan_returns_match() -> None:
# Arrange
plans = [PLAN_A, PLAN_B]
# Act
result = find_matching_plan(plans, "2 Laburnum Way Bromley BR2 8BZ")
# Assert
assert result is not None
assert result.id == "plan-a"
def test_find_matching_plan_postcode_mismatch_returns_none() -> None:
# Arrange
plans = [PLAN_A]
# Act
result = find_matching_plan(plans, "2 Laburnum Way Bromley SW1A 1AA")
# Assert
assert result is None
def test_find_matching_plan_street_mismatch_returns_none() -> None:
# Arrange
plans = [PLAN_A]
# Act
result = find_matching_plan(plans, "99 Other Road Bromley BR2 8BZ")
# Assert
assert result is None
def test_find_matching_plan_empty_list_returns_none() -> None:
# Act
result = find_matching_plan([], "2 Laburnum Way Bromley BR2 8BZ")
# Assert
assert result is None
def test_find_matching_plan_postcode_with_no_space_in_address() -> None:
# Arrange - address has postcode without internal space
plans = [PLAN_A]
# Act
result = find_matching_plan(plans, "2 Laburnum Way Bromley BR28BZ")
# Assert
assert result is not None
assert result.id == "plan-a"
def test_find_matching_plan_plan_postcode_with_no_space() -> None:
# Arrange - plan has postcode without space
plan = _make_plan(
"plan-c", street="Laburnum Way", street_number="2", postal_code="BR28BZ"
)
# Act
result = find_matching_plan([plan], "2 Laburnum Way Bromley BR2 8BZ")
# Assert
assert result is not None
assert result.id == "plan-c"
def test_find_matching_plan_no_postcode_in_address_returns_none() -> None:
# Act
result = find_matching_plan([PLAN_A], "2 Laburnum Way Bromley")
# Assert
assert result is None
def test_find_matching_plan_second_plan_matches() -> None:
# Arrange
plans = [PLAN_A, PLAN_B]
# Act
result = find_matching_plan(plans, "11 Station Road Bromley BR1 3LP")
# Assert
assert result is not None
assert result.id == "plan-b"

View file

@ -0,0 +1,103 @@
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
from pydantic import ValidationError
from backend.magic_plan.handler import handler
ADDRESS = "2 Laburnum Way Bromley BR2 8BZ"
PLAN_UID = "a7285ed1-878d-47eb-8aa6-85ef9e187516"
def _make_settings(**overrides: str) -> MagicMock:
settings = MagicMock()
settings.MAGICPLAN_CUSTOMER_ID = overrides.get("customer_id", "cust-123")
settings.MAGICPLAN_API_KEY = overrides.get("api_key", "key-abc")
return settings
def _call_handler(body: dict[str, Any]) -> Any:
return handler.__wrapped__(body, None) # type: ignore[attr-defined]
@pytest.fixture()
def mock_plan() -> MagicMock:
plan = MagicMock()
plan.uid = PLAN_UID
return plan
@pytest.fixture()
def mock_service(mock_plan: MagicMock) -> MagicMock:
service = MagicMock()
service.run.return_value = mock_plan
return service
# --- request validation ---
def test_handler_raises_on_missing_address(mock_plan: MagicMock) -> None:
# Arrange
body: dict[str, Any] = {}
with patch("backend.magic_plan.handler.get_settings", return_value=_make_settings()), \
patch("backend.magic_plan.handler.MagicPlanClient"), \
patch("backend.magic_plan.handler.MagicPlanService"):
# Act / Assert
with pytest.raises(ValidationError):
_call_handler(body)
# --- client construction ---
def test_handler_constructs_client_from_settings(mock_service: MagicMock) -> None:
# Arrange
body = {"address": ADDRESS}
with patch("backend.magic_plan.handler.get_settings", return_value=_make_settings(customer_id="cust-xyz", api_key="key-xyz")), \
patch("backend.magic_plan.handler.MagicPlanClient") as MockClient, \
patch("backend.magic_plan.handler.MagicPlanService", return_value=mock_service):
# Act
_call_handler(body)
# Assert
MockClient.assert_called_once_with(customer_id="cust-xyz", api_key="key-xyz")
# --- service orchestration ---
def test_handler_calls_service_run_with_address(mock_service: MagicMock) -> None:
# Arrange
body = {"address": ADDRESS}
with patch("backend.magic_plan.handler.get_settings", return_value=_make_settings()), \
patch("backend.magic_plan.handler.MagicPlanClient"), \
patch("backend.magic_plan.handler.MagicPlanService", return_value=mock_service):
# Act
_call_handler(body)
# Assert
mock_service.run.assert_called_once_with(ADDRESS, None)
def test_handler_passes_uprn_to_service(mock_service: MagicMock) -> None:
# Arrange
body = {"address": ADDRESS, "uprn": "100023336956"}
with patch("backend.magic_plan.handler.get_settings", return_value=_make_settings()), \
patch("backend.magic_plan.handler.MagicPlanClient"), \
patch("backend.magic_plan.handler.MagicPlanService", return_value=mock_service):
# Act
_call_handler(body)
# Assert
mock_service.run.assert_called_once_with(ADDRESS, "100023336956")
def test_handler_returns_plan_uid(mock_service: MagicMock) -> None:
# Arrange
body = {"address": ADDRESS}
with patch("backend.magic_plan.handler.get_settings", return_value=_make_settings()), \
patch("backend.magic_plan.handler.MagicPlanClient"), \
patch("backend.magic_plan.handler.MagicPlanService", return_value=mock_service):
# Act
result = _call_handler(body)
# Assert
assert result == PLAN_UID

View file

@ -0,0 +1,174 @@
import json
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
import requests
from backend.magic_plan.magic_plan_client import MagicPlanClient
from datatypes.magicplan.api.response import MagicPlanPlan, PlansListResponse
FIXTURE_DIR = Path(__file__).parents[2] / "magic_plan"
BASE_URL = "https://cloud.magicplan.app/api/v2"
CUSTOMER_ID = "test-customer"
API_KEY = "test-key"
def _load_fixture(name: str) -> dict[str, Any]:
return json.loads((FIXTURE_DIR / name).read_text())
def _make_client(mock_session: MagicMock) -> MagicPlanClient:
with patch(
"backend.magic_plan.magic_plan_client.requests.Session",
return_value=mock_session,
):
return MagicPlanClient(customer_id=CUSTOMER_ID, api_key=API_KEY)
@pytest.fixture()
def mock_session() -> MagicMock:
return MagicMock()
@pytest.fixture()
def client(mock_session: MagicMock) -> MagicPlanClient:
return _make_client(mock_session)
# --- constructor ---
def test_customer_header_set_on_session(mock_session: MagicMock) -> None:
# Act
_make_client(mock_session)
# Assert
mock_session.headers.update.assert_called_once_with({"customer": CUSTOMER_ID})
# --- get_plans ---
def test_get_plans_calls_correct_url(
client: MagicPlanClient, mock_session: MagicMock
) -> None:
# Arrange
plans_data = _load_fixture("magicplan_api_plans_response_example.json")["data"]
mock_session.get.return_value.json.return_value = {
"message": "OK",
"data": plans_data,
}
# Act
client.get_plans()
# Assert
mock_session.get.assert_called_once_with(
f"{BASE_URL}/plans", params={"key": API_KEY}
)
def test_get_plans_calls_raise_for_status(
client: MagicPlanClient, mock_session: MagicMock
) -> None:
# Arrange
plans_data = _load_fixture("magicplan_api_plans_response_example.json")["data"]
mock_session.get.return_value.json.return_value = {
"message": "OK",
"data": plans_data,
}
# Act
client.get_plans()
# Assert
mock_session.get.return_value.raise_for_status.assert_called_once()
def test_get_plans_returns_plans_list_response(
client: MagicPlanClient, mock_session: MagicMock
) -> None:
# Arrange
plans_data = _load_fixture("magicplan_api_plans_response_example.json")["data"]
mock_session.get.return_value.json.return_value = {
"message": "OK",
"data": plans_data,
}
# Act
result = client.get_plans()
# Assert
assert isinstance(result, PlansListResponse)
assert len(result.plans) == 1
def test_get_plans_propagates_http_error(
client: MagicPlanClient, mock_session: MagicMock
) -> None:
# Arrange
mock_session.get.return_value.raise_for_status.side_effect = requests.HTTPError(
"404"
)
# Act / Assert
with pytest.raises(requests.HTTPError):
client.get_plans()
# --- get_plan ---
def test_get_plan_calls_correct_url(
client: MagicPlanClient, mock_session: MagicMock
) -> None:
# Arrange
plan_data = _load_fixture("magicplan_api_plan_response_example.json")["data"]
mock_session.get.return_value.json.return_value = {
"message": "OK",
"data": plan_data,
}
plan_id = "a7285ed1-878d-47eb-8aa6-85ef9e187516"
# Act
client.get_plan(plan_id)
# Assert
mock_session.get.assert_called_once_with(
f"{BASE_URL}/plans/{plan_id}", params={"key": API_KEY}
)
def test_get_plan_calls_raise_for_status(
client: MagicPlanClient, mock_session: MagicMock
) -> None:
# Arrange
plan_data = _load_fixture("magicplan_api_plan_response_example.json")["data"]
mock_session.get.return_value.json.return_value = {
"message": "OK",
"data": plan_data,
}
# Act
client.get_plan("a7285ed1-878d-47eb-8aa6-85ef9e187516")
# Assert
mock_session.get.return_value.raise_for_status.assert_called_once()
def test_get_plan_returns_magic_plan(
client: MagicPlanClient, mock_session: MagicMock
) -> None:
# Arrange
plan_data = _load_fixture("magicplan_api_plan_response_example.json")["data"]
mock_session.get.return_value.json.return_value = {
"message": "OK",
"data": plan_data,
}
# Act
result = client.get_plan("a7285ed1-878d-47eb-8aa6-85ef9e187516")
# Assert
assert isinstance(result, MagicPlanPlan)
assert result.plan.id == "a7285ed1-878d-47eb-8aa6-85ef9e187516"
def test_get_plan_propagates_http_error(
client: MagicPlanClient, mock_session: MagicMock
) -> None:
# Arrange
mock_session.get.return_value.raise_for_status.side_effect = requests.HTTPError(
"500"
)
# Act / Assert
with pytest.raises(requests.HTTPError):
client.get_plan("some-id")

View file

@ -0,0 +1,146 @@
import json
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from datatypes.magicplan.api.response import MagicPlanPlan, PlanSummary
from datatypes.magicplan.domain.mapper import map_plan
from datatypes.magicplan.domain.models import Plan
from backend.magic_plan.magic_plan_client import MagicPlanClient
from backend.magic_plan.magic_plan_service import MagicPlanService
FIXTURE_DIR = Path(__file__).parents[2] / "magic_plan"
PLAN_ID = "a7285ed1-878d-47eb-8aa6-85ef9e187516"
@pytest.fixture(scope="module")
def domain_plan() -> Plan:
data = json.loads(
(FIXTURE_DIR / "magicplan_api_plan_response_example.json").read_text()
)
return map_plan(MagicPlanPlan.model_validate(data["data"]))
@pytest.fixture(scope="module")
def api_magic_plan() -> MagicPlanPlan:
data = json.loads(
(FIXTURE_DIR / "magicplan_api_plan_response_example.json").read_text()
)
return MagicPlanPlan.model_validate(data["data"])
@pytest.fixture(scope="module")
def plan_summary() -> PlanSummary:
data = json.loads(
(FIXTURE_DIR / "magicplan_api_plan_response_example.json").read_text()
)
return MagicPlanPlan.model_validate(data["data"]).plan
@pytest.fixture()
def mock_client() -> MagicMock:
return MagicMock(spec=MagicPlanClient)
def _make_service(mock_client: MagicMock) -> MagicPlanService:
return MagicPlanService(client=mock_client)
# --- no match ---
def test_run_raises_when_no_plan_found(mock_client: MagicMock) -> None:
# Arrange
mock_client.get_plans.return_value.plans = []
service = _make_service(mock_client)
# Act / Assert
with pytest.raises(ValueError, match="No MagicPlan found"):
service.run("99 Nowhere Road London SW1A 1AA")
# --- match found ---
def test_run_fetches_plan_with_matched_id(
mock_client: MagicMock,
api_magic_plan: MagicPlanPlan,
plan_summary: PlanSummary,
domain_plan: Plan,
) -> None:
# Arrange
mock_client.get_plans.return_value.plans = [plan_summary]
mock_client.get_plan.return_value = api_magic_plan
service = _make_service(mock_client)
with patch(
"backend.magic_plan.magic_plan_service.find_matching_plan",
return_value=plan_summary,
), patch("backend.magic_plan.magic_plan_service.save_plan"), patch(
"backend.magic_plan.magic_plan_service.db_session"
):
service.run("2 Laburnum Way Bromley BR2 8BZ")
# Assert
mock_client.get_plan.assert_called_once_with(plan_summary.id)
def test_run_returns_mapped_plan(
mock_client: MagicMock,
api_magic_plan: MagicPlanPlan,
plan_summary: PlanSummary,
domain_plan: Plan,
) -> None:
# Arrange
mock_client.get_plans.return_value.plans = [plan_summary]
mock_client.get_plan.return_value = api_magic_plan
service = _make_service(mock_client)
with patch(
"backend.magic_plan.magic_plan_service.find_matching_plan",
return_value=plan_summary,
), patch("backend.magic_plan.magic_plan_service.save_plan"), patch(
"backend.magic_plan.magic_plan_service.db_session"
):
result = service.run("2 Laburnum Way Bromley BR2 8BZ")
# Assert
assert isinstance(result, Plan)
assert result.uid == PLAN_ID
def test_run_calls_save_plan_with_mapped_plan(
mock_client: MagicMock,
api_magic_plan: MagicPlanPlan,
plan_summary: PlanSummary,
) -> None:
# Arrange
mock_client.get_plans.return_value.plans = [plan_summary]
mock_client.get_plan.return_value = api_magic_plan
service = _make_service(mock_client)
with patch(
"backend.magic_plan.magic_plan_service.find_matching_plan",
return_value=plan_summary,
), patch("backend.magic_plan.magic_plan_service.save_plan") as mock_save, patch(
"backend.magic_plan.magic_plan_service.db_session"
):
service.run("2 Laburnum Way Bromley BR2 8BZ")
# Assert — save_plan called with a Plan whose uid matches
call_args = mock_save.call_args
saved_plan: Plan = call_args[0][1]
assert saved_plan.uid == PLAN_ID
def test_run_accepts_uprn_without_error(
mock_client: MagicMock,
api_magic_plan: MagicPlanPlan,
plan_summary: PlanSummary,
) -> None:
# Arrange
mock_client.get_plans.return_value.plans = [plan_summary]
mock_client.get_plan.return_value = api_magic_plan
service = _make_service(mock_client)
with patch(
"backend.magic_plan.magic_plan_service.find_matching_plan",
return_value=plan_summary,
), patch("backend.magic_plan.magic_plan_service.save_plan"), patch(
"backend.magic_plan.magic_plan_service.db_session"
):
service.run("2 Laburnum Way Bromley BR2 8BZ", uprn="100023336956")

View file

@ -0,0 +1,40 @@
import pytest
from pydantic import ValidationError
from backend.magic_plan.magic_plan_trigger_request import MagicPlanTriggerRequest
def test_valid_payload_with_address_only() -> None:
# Arrange
payload = {"address": "123 High St London SW1A 1AA"}
# Act
req = MagicPlanTriggerRequest.model_validate(payload)
# Assert
assert req.address == "123 High St London SW1A 1AA"
assert req.uprn is None
def test_valid_payload_with_uprn() -> None:
# Arrange
payload = {"address": "123 High St London SW1A 1AA", "uprn": "100023336956"}
# Act
req = MagicPlanTriggerRequest.model_validate(payload)
# Assert
assert req.uprn == "100023336956"
def test_missing_address_raises() -> None:
# Arrange
payload = {"uprn": "100023336956"}
# Act / Assert
with pytest.raises(ValidationError):
MagicPlanTriggerRequest.model_validate(payload)
def test_extra_fields_ignored() -> None:
# Arrange
payload = {"address": "123 High St London SW1A 1AA", "unknown_field": "whatever"}
# Act
req = MagicPlanTriggerRequest.model_validate(payload)
# Assert
assert req.address == "123 High St London SW1A 1AA"

View file

@ -2,7 +2,6 @@ from typing import Any, Optional, Union
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
_IGNORE = ConfigDict(extra="ignore") _IGNORE = ConfigDict(extra="ignore")
_IGNORE_POPULATE = ConfigDict(extra="ignore", populate_by_name=True) _IGNORE_POPULATE = ConfigDict(extra="ignore", populate_by_name=True)
@ -274,7 +273,20 @@ class PlanSummary(BaseModel):
created_by: Optional[CreatedBy] = None created_by: Optional[CreatedBy] = None
class MagicPlan(BaseModel): class Paging(BaseModel):
model_config = _IGNORE
page: int
next_page: bool
count: int
class PlansListResponse(BaseModel):
model_config = _IGNORE
paging: Paging
plans: list[PlanSummary] = []
class MagicPlanPlan(BaseModel):
model_config = _IGNORE model_config = _IGNORE
plan: PlanSummary plan: PlanSummary
plan_detail: PlanDetail plan_detail: PlanDetail

View file

@ -4,7 +4,7 @@ from typing import Any
import pytest import pytest
from datatypes.magicplan.api.response import MagicPlan from datatypes.magicplan.api.response import MagicPlanPlan, PlansListResponse
FIXTURE_DIR = Path(__file__).parents[4] / "backend" / "magic_plan" FIXTURE_DIR = Path(__file__).parents[4] / "backend" / "magic_plan"
PLAN_ID = "a7285ed1-878d-47eb-8aa6-85ef9e187516" PLAN_ID = "a7285ed1-878d-47eb-8aa6-85ef9e187516"
@ -19,51 +19,51 @@ def raw_data() -> dict[str, Any]:
@pytest.fixture(scope="module") @pytest.fixture(scope="module")
def mp(raw_data: dict[str, Any]) -> MagicPlan: def mp(raw_data: dict[str, Any]) -> MagicPlanPlan:
return MagicPlan.model_validate(raw_data) return MagicPlanPlan.model_validate(raw_data)
def test_model_validate_does_not_raise(raw_data: dict[str, Any]): def test_model_validate_does_not_raise(raw_data: dict[str, Any]):
# act # act
MagicPlan.model_validate(raw_data) MagicPlanPlan.model_validate(raw_data)
def test_plan_id(mp: MagicPlan): def test_plan_id(mp: MagicPlanPlan):
# assert # assert
assert mp.plan.id == PLAN_ID assert mp.plan.id == PLAN_ID
def test_url_3d_alias(mp: MagicPlan): def test_url_3d_alias(mp: MagicPlanPlan):
# assert # assert
assert mp.plan.url_3d is not None assert mp.plan.url_3d is not None
assert mp.plan.url_3d.startswith("http") assert mp.plan.url_3d.startswith("http")
def test_floor_count(mp: MagicPlan): def test_floor_count(mp: MagicPlanPlan):
# assert # assert
assert len(mp.plan_detail.plan.floors) == 2 assert len(mp.plan_detail.plan.floors) == 2
def test_first_room_name(mp: MagicPlan): def test_first_room_name(mp: MagicPlanPlan):
# assert # assert
assert mp.plan_detail.plan.floors[0].rooms[0].name == "Kitchen" assert mp.plan_detail.plan.floors[0].rooms[0].name == "Kitchen"
def test_room_area_is_float(mp: MagicPlan): def test_room_area_is_float(mp: MagicPlanPlan):
# arrange # arrange
room = mp.plan_detail.plan.floors[0].rooms[0] room = mp.plan_detail.plan.floors[0].rooms[0]
# assert # assert
assert isinstance(room.area, float) assert isinstance(room.area, float)
def test_wall_item_symbol_id(mp: MagicPlan): def test_wall_item_symbol_id(mp: MagicPlanPlan):
# arrange # arrange
room = mp.plan_detail.plan.floors[0].rooms[0] room = mp.plan_detail.plan.floors[0].rooms[0]
# assert # assert
assert room.wall_items[0].symbol.id != "" assert room.wall_items[0].symbol.id != ""
def test_field_value_array(mp: MagicPlan): def test_field_value_array(mp: MagicPlanPlan):
# arrange # arrange
room = mp.plan_detail.plan.floors[0].rooms[0] room = mp.plan_detail.plan.floors[0].rooms[0]
array_field = next(f for f in room.displayable_fields if f.value.is_array) array_field = next(f for f in room.displayable_fields if f.value.is_array)
@ -71,7 +71,7 @@ def test_field_value_array(mp: MagicPlan):
assert isinstance(array_field.value.value, list) assert isinstance(array_field.value.value, list)
def test_field_value_scalar(mp: MagicPlan): def test_field_value_scalar(mp: MagicPlanPlan):
# arrange # arrange
room = mp.plan_detail.plan.floors[0].rooms[0] room = mp.plan_detail.plan.floors[0].rooms[0]
scalar_field = next(f for f in room.displayable_fields if not f.value.is_array) scalar_field = next(f for f in room.displayable_fields if not f.value.is_array)
@ -83,4 +83,61 @@ def test_extra_fields_ignored(raw_data: dict[str, Any]):
# arrange # arrange
data_with_extra = {**raw_data, "unknown_future_field": "whatever"} data_with_extra = {**raw_data, "unknown_future_field": "whatever"}
# act # act
MagicPlan.model_validate(data_with_extra) MagicPlanPlan.model_validate(data_with_extra)
# --- PlansListResponse ---
@pytest.fixture(scope="module")
def plans_raw_data() -> dict[str, Any]:
payload = json.loads(
(FIXTURE_DIR / "magicplan_api_plans_response_example.json").read_text()
)
return payload["data"]
@pytest.fixture(scope="module")
def plans_response(plans_raw_data: dict[str, Any]) -> PlansListResponse:
return PlansListResponse.model_validate(plans_raw_data)
def test_plans_list_model_validate_does_not_raise(
plans_raw_data: dict[str, Any],
) -> None:
# act
PlansListResponse.model_validate(plans_raw_data)
def test_plans_list_count(plans_response: PlansListResponse) -> None:
# assert
assert len(plans_response.plans) == 1
def test_plans_list_first_plan_id(plans_response: PlansListResponse) -> None:
# assert
assert plans_response.plans[0].id == "9f9889ff-793e-4e9a-a6f0-e22f5b0f5365"
def test_plans_list_paging_page(plans_response: PlansListResponse) -> None:
# assert
assert plans_response.paging.page == 1
def test_plans_list_paging_next_page_is_false(
plans_response: PlansListResponse,
) -> None:
# assert
assert plans_response.paging.next_page is False
def test_plans_list_paging_count(plans_response: PlansListResponse) -> None:
# assert
assert plans_response.paging.count == 1
def test_plans_list_unknown_keys_ignored(plans_raw_data: dict[str, Any]) -> None:
# arrange
data_with_extra = {**plans_raw_data, "unknown_future_field": "whatever"}
# act
PlansListResponse.model_validate(data_with_extra)

View file

@ -1,9 +1,11 @@
from typing import Optional
import datatypes.magicplan.api.response as api import datatypes.magicplan.api.response as api
from datatypes.magicplan.api.response import MagicPlan from datatypes.magicplan.api.response import MagicPlanPlan
from datatypes.magicplan.domain.models import Plan, Floor, Room, Window, Door from datatypes.magicplan.domain.models import Plan, Floor, Room, Window, Door
def map_plan(mp: MagicPlan) -> Plan: def map_plan(mp: MagicPlanPlan) -> Plan:
return Plan( return Plan(
uid=mp.plan.id, uid=mp.plan.id,
name=mp.plan.name, name=mp.plan.name,
@ -13,7 +15,7 @@ def map_plan(mp: MagicPlan) -> Plan:
) )
def _map_address(addr: api.Address | None) -> str | None: def _map_address(addr: Optional[api.Address]) -> Optional[str]:
if addr is None: if addr is None:
return None return None
street = " ".join(p for p in [addr.street_number, addr.street] if p) or None street = " ".join(p for p in [addr.street_number, addr.street] if p) or None
@ -43,7 +45,7 @@ def _map_room(r: api.Room) -> Room:
) )
def _parse_dimensions(dimensions: str | None) -> tuple[float, float]: def _parse_dimensions(dimensions: Optional[str]) -> tuple[float, float]:
if not dimensions: if not dimensions:
return 0.0, 0.0 return 0.0, 0.0
parts = dimensions.split(" x ") parts = dimensions.split(" x ")

View file

@ -4,7 +4,7 @@ from typing import Any
import pytest import pytest
from datatypes.magicplan.api.response import MagicPlan from datatypes.magicplan.api.response import MagicPlanPlan
from datatypes.magicplan.domain.mapper import map_plan from datatypes.magicplan.domain.mapper import map_plan
from datatypes.magicplan.domain.models import Plan from datatypes.magicplan.domain.models import Plan
@ -22,12 +22,12 @@ def raw_data() -> dict[str, Any]:
@pytest.fixture(scope="module") @pytest.fixture(scope="module")
def mp(raw_data: dict[str, Any]) -> MagicPlan: def mp(raw_data: dict[str, Any]) -> MagicPlanPlan:
return MagicPlan.model_validate(raw_data) return MagicPlanPlan.model_validate(raw_data)
@pytest.fixture(scope="module") @pytest.fixture(scope="module")
def plan(mp: MagicPlan) -> Plan: def plan(mp: MagicPlanPlan) -> Plan:
return map_plan(mp) return map_plan(mp)
@ -119,7 +119,7 @@ def raw_data_2() -> dict[str, Any]:
@pytest.fixture(scope="module") @pytest.fixture(scope="module")
def plan2(raw_data_2: dict[str, Any]) -> Plan: def plan2(raw_data_2: dict[str, Any]) -> Plan:
return map_plan(MagicPlan.model_validate(raw_data_2)) return map_plan(MagicPlanPlan.model_validate(raw_data_2))
def test_plan2_uid(plan2: Plan): def test_plan2_uid(plan2: Plan):
@ -201,7 +201,7 @@ def plan3() -> Plan:
payload = json.loads( payload = json.loads(
(FIXTURE_DIR / "magicplan_api_plan_response_example_3.json").read_text() (FIXTURE_DIR / "magicplan_api_plan_response_example_3.json").read_text()
) )
return map_plan(MagicPlan.model_validate(payload["data"])) return map_plan(MagicPlanPlan.model_validate(payload["data"]))
def test_plan3_address_uses_street_number_and_omits_city(plan3: Plan): def test_plan3_address_uses_street_number_and_omits_city(plan3: Plan):
@ -220,7 +220,7 @@ def plan4() -> Plan:
payload = json.loads( payload = json.loads(
(FIXTURE_DIR / "magicplan_api_plan_response_example_4.json").read_text() (FIXTURE_DIR / "magicplan_api_plan_response_example_4.json").read_text()
) )
return map_plan(MagicPlan.model_validate(payload["data"])) return map_plan(MagicPlanPlan.model_validate(payload["data"]))
def test_plan4_address_uses_street_number_when_street_absent(plan4: Plan): def test_plan4_address_uses_street_number_when_street_absent(plan4: Plan):

View file

@ -3,6 +3,6 @@ pythonpath = .
log_cli = true log_cli = true
log_cli_level = INFO log_cli_level = INFO
addopts = --cov-report term-missing --cov=etl/epc --cov=recommendations --cov=backend --cov=etl/epc_clean --cov=etl/spatial addopts = --cov-report term-missing --cov=etl/epc --cov=recommendations --cov=backend --cov=etl/epc_clean --cov=etl/spatial
testpaths = recommendations/tests backend/tests etl/epc/tests etl/epc_clean/tests etl/spatial/tests backend/condition/tests backend/address2UPRN/tests backend/onboarders/tests backend/categorisation/tests backend/export/tests etl/hubspot/tests backend/hubspot_trigger_orchestrator/tests datatypes/epc/schema/tests datatypes/epc/surveys/tests datatypes/epc/domain/tests backend/ecmk_fetcher/tests/ backend/pashub_fetcher/tests backend/documents_parser/tests backend/magic_plan/tests datatypes/magicplan/api/tests datatypes/magicplan/domain/tests testpaths = recommendations/tests backend/tests etl/epc/tests etl/epc_clean/tests etl/spatial/tests backend/condition/tests backend/address2UPRN/tests backend/onboarders/tests backend/categorisation/tests backend/export/tests etl/hubspot/tests backend/hubspot_trigger_orchestrator/tests datatypes/epc/schema/tests datatypes/epc/surveys/tests datatypes/epc/domain/tests backend/ecmk_fetcher/tests/ backend/pashub_fetcher/tests backend/documents_parser/tests backend/magic_plan/tests datatypes/magicplan/api/tests datatypes/magicplan/domain/tests backend/app/db/functions/tests
markers = markers =
integration: mark a test as an integration test integration: mark a test as an integration test