From 79db1e2c7ed4bdc628a803d4d3d45f1105c52878 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 7 May 2026 12:22:04 +0000 Subject: [PATCH 01/19] =?UTF-8?q?validate=20plans=20api=20response=20as=20?= =?UTF-8?q?pydantic=20basemodel=20object=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../magicplan_api_plans_response_example.json | 39 +++++++++++++++++++ .../magicplan/api/tests/test_response.py | 23 ++++++++++- 2 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 backend/magic_plan/magicplan_api_plans_response_example.json diff --git a/backend/magic_plan/magicplan_api_plans_response_example.json b/backend/magic_plan/magicplan_api_plans_response_example.json new file mode 100644 index 00000000..b8fcf1f9 --- /dev/null +++ b/backend/magic_plan/magicplan_api_plans_response_example.json @@ -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" + } + } + ] + } +} \ No newline at end of file diff --git a/datatypes/magicplan/api/tests/test_response.py b/datatypes/magicplan/api/tests/test_response.py index 663da9ca..84f8f6d0 100644 --- a/datatypes/magicplan/api/tests/test_response.py +++ b/datatypes/magicplan/api/tests/test_response.py @@ -4,7 +4,7 @@ from typing import Any import pytest -from datatypes.magicplan.api.response import MagicPlan +from datatypes.magicplan.api.response import MagicPlan, PlansListResponse FIXTURE_DIR = Path(__file__).parents[4] / "backend" / "magic_plan" PLAN_ID = "a7285ed1-878d-47eb-8aa6-85ef9e187516" @@ -84,3 +84,24 @@ def test_extra_fields_ignored(raw_data: dict[str, Any]): data_with_extra = {**raw_data, "unknown_future_field": "whatever"} # act MagicPlan.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) From e67604a4a3f19c09fe9619552e9c67c16bfc8a7d Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 7 May 2026 12:23:30 +0000 Subject: [PATCH 02/19] =?UTF-8?q?validate=20plans=20api=20response=20as=20?= =?UTF-8?q?pydantic=20basemodel=20object=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- datatypes/magicplan/api/response.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/datatypes/magicplan/api/response.py b/datatypes/magicplan/api/response.py index 8e704c65..2fc3738d 100644 --- a/datatypes/magicplan/api/response.py +++ b/datatypes/magicplan/api/response.py @@ -274,6 +274,19 @@ class PlanSummary(BaseModel): created_by: Optional[CreatedBy] = None +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 MagicPlan(BaseModel): model_config = _IGNORE plan: PlanSummary From 06567c28442aaf0258bc3080c97d520eb3976c86 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 7 May 2026 12:25:06 +0000 Subject: [PATCH 03/19] =?UTF-8?q?validate=20plans=20api=20response=20as=20?= =?UTF-8?q?pydantic=20basemodel=20object=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../magicplan/api/tests/test_response.py | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/datatypes/magicplan/api/tests/test_response.py b/datatypes/magicplan/api/tests/test_response.py index 84f8f6d0..469b7f14 100644 --- a/datatypes/magicplan/api/tests/test_response.py +++ b/datatypes/magicplan/api/tests/test_response.py @@ -105,3 +105,35 @@ def plans_response(plans_raw_data: dict[str, Any]) -> PlansListResponse: 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) From 5b92c3130228b87ba23bbaf877bd329f1de67a07 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 7 May 2026 12:38:09 +0000 Subject: [PATCH 04/19] =?UTF-8?q?validate=20magicplan=20trigger=20request?= =?UTF-8?q?=20basemodel=20object=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/magic_plan/__init__.py | 0 .../magic_plan/magic_plan_trigger_request.py | 10 +++++ backend/magic_plan/tests/__init__.py | 0 .../tests/test_magic_plan_trigger_request.py | 40 +++++++++++++++++++ 4 files changed, 50 insertions(+) create mode 100644 backend/magic_plan/__init__.py create mode 100644 backend/magic_plan/magic_plan_trigger_request.py create mode 100644 backend/magic_plan/tests/__init__.py create mode 100644 backend/magic_plan/tests/test_magic_plan_trigger_request.py diff --git a/backend/magic_plan/__init__.py b/backend/magic_plan/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/magic_plan/magic_plan_trigger_request.py b/backend/magic_plan/magic_plan_trigger_request.py new file mode 100644 index 00000000..bb0151e4 --- /dev/null +++ b/backend/magic_plan/magic_plan_trigger_request.py @@ -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 diff --git a/backend/magic_plan/tests/__init__.py b/backend/magic_plan/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/magic_plan/tests/test_magic_plan_trigger_request.py b/backend/magic_plan/tests/test_magic_plan_trigger_request.py new file mode 100644 index 00000000..46a20a37 --- /dev/null +++ b/backend/magic_plan/tests/test_magic_plan_trigger_request.py @@ -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" From 61b91bdb5ec7d24d6f7bb39b7b661834af381c6e Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 7 May 2026 12:39:45 +0000 Subject: [PATCH 05/19] add magicplan environment variables to config --- backend/app/config.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/app/config.py b/backend/app/config.py index 70a6b50c..e939d6e4 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -45,6 +45,8 @@ class Settings(BaseSettings): # Third parties EPC_AUTH_TOKEN: str = "changeme" GOOGLE_SOLAR_API_KEY: str = "changeme" + MAGICPLAN_CUSTOMER_ID: str = "changeme" + MAGICPLAN_API_KEY: str = "changeme" # Database settings DB_HOST: str = "changeme" From 8e12ac705b798899239af2983ab23381db0fb970 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 7 May 2026 12:41:15 +0000 Subject: [PATCH 06/19] =?UTF-8?q?match=20address=20string=20to=20magicplan?= =?UTF-8?q?=20plan=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../magic_plan/tests/test_address_matcher.py | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 backend/magic_plan/tests/test_address_matcher.py diff --git a/backend/magic_plan/tests/test_address_matcher.py b/backend/magic_plan/tests/test_address_matcher.py new file mode 100644 index 00000000..2ee1f687 --- /dev/null +++ b/backend/magic_plan/tests/test_address_matcher.py @@ -0,0 +1,125 @@ +import pytest + +from datatypes.magicplan.api.response import Address, 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" From 60c69c478640ba47915ac312807a54d7052abb34 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 7 May 2026 12:43:45 +0000 Subject: [PATCH 07/19] =?UTF-8?q?match=20address=20string=20to=20magicplan?= =?UTF-8?q?=20plan=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/magic_plan/address_matcher.py | 47 +++++++++++++++++++ .../magic_plan/tests/test_address_matcher.py | 16 ++++--- 2 files changed, 57 insertions(+), 6 deletions(-) create mode 100644 backend/magic_plan/address_matcher.py diff --git a/backend/magic_plan/address_matcher.py b/backend/magic_plan/address_matcher.py new file mode 100644 index 00000000..043358c0 --- /dev/null +++ b/backend/magic_plan/address_matcher.py @@ -0,0 +1,47 @@ +import re + +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) -> PlanSummary | None: + 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 diff --git a/backend/magic_plan/tests/test_address_matcher.py b/backend/magic_plan/tests/test_address_matcher.py index 2ee1f687..347a49ef 100644 --- a/backend/magic_plan/tests/test_address_matcher.py +++ b/backend/magic_plan/tests/test_address_matcher.py @@ -1,6 +1,4 @@ -import pytest - -from datatypes.magicplan.api.response import Address, PlanSummary +from datatypes.magicplan.api.response import PlanSummary from backend.magic_plan.address_matcher import find_matching_plan, _extract_postcode @@ -49,8 +47,12 @@ def test_extract_postcode_none_for_empty_string() -> 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") +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: @@ -100,7 +102,9 @@ def test_find_matching_plan_postcode_with_no_space_in_address() -> None: 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") + 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 From ef5c44f7ae65c28a98c0437f197842c269ac5682 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 7 May 2026 12:49:15 +0000 Subject: [PATCH 08/19] =?UTF-8?q?MagicPlan=20HTTP=20client=20with=20auth?= =?UTF-8?q?=20headers=20and=20response=20parsing=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tests/test_magic_plan_client.py | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 backend/magic_plan/tests/test_magic_plan_client.py diff --git a/backend/magic_plan/tests/test_magic_plan_client.py b/backend/magic_plan/tests/test_magic_plan_client.py new file mode 100644 index 00000000..f8f35432 --- /dev/null +++ b/backend/magic_plan/tests/test_magic_plan_client.py @@ -0,0 +1,133 @@ +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 MagicPlan, 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(spec=requests.Session) + + +@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, MagicPlan) + 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") From bbcde75b8e9c964e7c9e33845af4e7d1af8ad6f1 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 7 May 2026 12:51:41 +0000 Subject: [PATCH 09/19] =?UTF-8?q?MagicPlan=20HTTP=20client=20with=20auth?= =?UTF-8?q?=20headers=20and=20response=20parsing=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/magic_plan/magic_plan_client.py | 22 +++++++++++++++++++ .../tests/test_magic_plan_client.py | 2 +- 2 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 backend/magic_plan/magic_plan_client.py diff --git a/backend/magic_plan/magic_plan_client.py b/backend/magic_plan/magic_plan_client.py new file mode 100644 index 00000000..ff99062d --- /dev/null +++ b/backend/magic_plan/magic_plan_client.py @@ -0,0 +1,22 @@ +import requests + +from datatypes.magicplan.api.response import MagicPlan, 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) -> MagicPlan: + r = self._session.get(f"{_BASE_URL}/plans/{plan_id}", params={"key": self._api_key}) + r.raise_for_status() + return MagicPlan.model_validate(r.json()["data"]) diff --git a/backend/magic_plan/tests/test_magic_plan_client.py b/backend/magic_plan/tests/test_magic_plan_client.py index f8f35432..02ae054c 100644 --- a/backend/magic_plan/tests/test_magic_plan_client.py +++ b/backend/magic_plan/tests/test_magic_plan_client.py @@ -26,7 +26,7 @@ def _make_client(mock_session: MagicMock) -> MagicPlanClient: @pytest.fixture() def mock_session() -> MagicMock: - return MagicMock(spec=requests.Session) + return MagicMock() @pytest.fixture() From 7ff7e90307db64a2e9c7fa7b2f12a44b732311a5 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 7 May 2026 12:56:12 +0000 Subject: [PATCH 10/19] =?UTF-8?q?SQLModel=20ORM=20layer=20for=20magic=5Fpl?= =?UTF-8?q?an=5Fplan/floor/room/window/door=20tables=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/db/models/magic_plan.py | 52 +++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 backend/app/db/models/magic_plan.py diff --git a/backend/app/db/models/magic_plan.py b/backend/app/db/models/magic_plan.py new file mode 100644 index 00000000..8a4ecd55 --- /dev/null +++ b/backend/app/db/models/magic_plan.py @@ -0,0 +1,52 @@ +from typing import Optional + +from sqlmodel import Field, SQLModel + + +class MagicPlanPlan(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 MagicPlanFloor(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 MagicPlanRoom(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 MagicPlanWindow(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 MagicPlanDoor(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 From 5ec0fa5d042628e45956f71b93f5e9d0e1edbdda Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 7 May 2026 13:01:04 +0000 Subject: [PATCH 11/19] =?UTF-8?q?save=5Fplan=20persisting=20domain=20Plan?= =?UTF-8?q?=20to=20magic=5Fplan=5F*=20tables=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/db/functions/tests/__init__.py | 0 .../tests/test_magic_plan_functions.py | 130 ++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 backend/app/db/functions/tests/__init__.py create mode 100644 backend/app/db/functions/tests/test_magic_plan_functions.py diff --git a/backend/app/db/functions/tests/__init__.py b/backend/app/db/functions/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/app/db/functions/tests/test_magic_plan_functions.py b/backend/app/db/functions/tests/test_magic_plan_functions.py new file mode 100644 index 00000000..c9785e26 --- /dev/null +++ b/backend/app/db/functions/tests/test_magic_plan_functions.py @@ -0,0 +1,130 @@ +import json +from pathlib import Path +from unittest.mock import MagicMock + +import pytest +from sqlalchemy.dialects import postgresql + +from datatypes.magicplan.api.response import MagicPlan +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 + +FIXTURE_DIR = Path(__file__).parents[4] / "magic_plan" +PLAN_UID = "a7285ed1-878d-47eb-8aa6-85ef9e187516" + +# fixture 1: 2 floors, 14 rooms total, 13 windows, 27 doors + + +@pytest.fixture(scope="module") +def domain_plan() -> Plan: + data = json.loads((FIXTURE_DIR / "magicplan_api_plan_response_example.json").read_text()) + return map_plan(MagicPlan.model_validate(data["data"])) + + +def _compiled(stmt: object) -> str: + return str(stmt.compile( # type: ignore[union-attr] + dialect=postgresql.dialect(), + compile_kwargs={"literal_binds": True}, + )) + + +@pytest.fixture() +def mock_session() -> MagicMock: + session = MagicMock() + + plan_result = MagicMock() + plan_result.scalar_one.return_value = 1 + + floor_result = MagicMock() + floor_result.scalars.return_value.all.return_value = [10, 20] + + room_result = MagicMock() + room_result.scalars.return_value.all.return_value = list(range(100, 114)) + + session.execute.side_effect = [ + plan_result, # upsert plan + None, # delete windows + None, # delete doors + None, # delete rooms + None, # delete floors + floor_result, # insert floors + room_result, # insert rooms + None, # insert windows + None, # insert doors + ] + return session + + +# --- save_plan orchestration --- + + +def test_save_plan_does_not_raise(mock_session: MagicMock, domain_plan: Plan) -> None: + # Act + save_plan(mock_session, domain_plan) + + +def test_save_plan_upserts_plan_table_first(mock_session: MagicMock, domain_plan: Plan) -> None: + # Act + save_plan(mock_session, domain_plan) + # Assert + first_stmt = mock_session.execute.call_args_list[0][0][0] + sql = _compiled(first_stmt) + assert "magic_plan_plan" in sql + assert "INSERT" in sql.upper() + + +def test_save_plan_upsert_contains_plan_uid(mock_session: MagicMock, domain_plan: Plan) -> None: + # Act + save_plan(mock_session, domain_plan) + # Assert + first_stmt = mock_session.execute.call_args_list[0][0][0] + assert PLAN_UID in _compiled(first_stmt) + + +def test_save_plan_upsert_contains_plan_name(mock_session: MagicMock, domain_plan: Plan) -> None: + # Act + save_plan(mock_session, domain_plan) + # Assert + first_stmt = mock_session.execute.call_args_list[0][0][0] + assert domain_plan.name in _compiled(first_stmt) + + +def test_save_plan_deletes_floors_before_inserting(mock_session: MagicMock, domain_plan: Plan) -> None: + # Act + save_plan(mock_session, domain_plan) + # Assert — find delete and insert stmts targeting magic_plan_floor + stmts = [_compiled(c[0][0]) for c in mock_session.execute.call_args_list] + floor_delete_idx = next(i for i, s in enumerate(stmts) if "DELETE" in s.upper() and "magic_plan_floor" in s) + floor_insert_idx = next(i for i, s in enumerate(stmts) if "INSERT" in s.upper() and "magic_plan_floor" in s) + assert floor_delete_idx < floor_insert_idx + + +def test_save_plan_inserts_correct_floor_count(mock_session: MagicMock, domain_plan: Plan) -> None: + # Act + save_plan(mock_session, domain_plan) + # Assert — floor INSERT contains values for both floors + stmts = [_compiled(c[0][0]) for c in mock_session.execute.call_args_list] + floor_insert = next(s for s in stmts if "INSERT" in s.upper() and "magic_plan_floor" in s) + # Each floor appears as a row — level values 0 and 1 from fixture + assert floor_insert.count("magic_plan_plan_id") >= len(domain_plan.floors) + + +def test_save_plan_inserts_correct_room_count(mock_session: MagicMock, domain_plan: Plan) -> None: + # Act + save_plan(mock_session, domain_plan) + # Assert — room INSERT contains values for all 14 rooms + total_rooms = sum(len(f.rooms) for f in domain_plan.floors) + stmts = [_compiled(c[0][0]) for c in mock_session.execute.call_args_list] + room_insert = next(s for s in stmts if "INSERT" in s.upper() and "magic_plan_room" in s) + assert room_insert.count("magic_plan_floor_id") >= total_rooms + + +def test_save_plan_windows_use_room_ids_from_insert(mock_session: MagicMock, domain_plan: Plan) -> None: + # Act + save_plan(mock_session, domain_plan) + # Assert — window INSERT references one of the mocked room ids (100–113) + stmts = [_compiled(c[0][0]) for c in mock_session.execute.call_args_list] + window_insert = next(s for s in stmts if "INSERT" in s.upper() and "magic_plan_window" in s) + assert any(str(rid) in window_insert for rid in range(100, 114)) From 2f8e08a6760f1a39eb88715d3a42bb47b299bf37 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 7 May 2026 13:09:09 +0000 Subject: [PATCH 12/19] =?UTF-8?q?save=5Fplan=20persisting=20domain=20Plan?= =?UTF-8?q?=20to=20magic=5Fplan=5F*=20tables=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/db/functions/magic_plan_functions.py | 116 ++++++++++++++++++ .../tests/test_magic_plan_functions.py | 17 +-- 2 files changed, 125 insertions(+), 8 deletions(-) create mode 100644 backend/app/db/functions/magic_plan_functions.py diff --git a/backend/app/db/functions/magic_plan_functions.py b/backend/app/db/functions/magic_plan_functions.py new file mode 100644 index 00000000..129ec958 --- /dev/null +++ b/backend/app/db/functions/magic_plan_functions.py @@ -0,0 +1,116 @@ +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 ( + MagicPlanDoor, + MagicPlanFloor, + MagicPlanPlan, + MagicPlanRoom, + MagicPlanWindow, +) + + +def save_plan(session: Session, plan: Plan) -> None: + plan_id = _upsert_plan(session, plan) + _delete_children(session, plan_id) + floor_ids = _insert_floors(session, plan.floors, plan_id) + room_ids = _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(MagicPlanPlan) + .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(MagicPlanPlan.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(MagicPlanFloor.id)) + .where(col(MagicPlanFloor.magic_plan_plan_id) == plan_id) + .scalar_subquery() + ) + room_subq = ( + select(col(MagicPlanRoom.id)) + .where(col(MagicPlanRoom.magic_plan_floor_id).in_(floor_subq)) + .scalar_subquery() + ) + session.execute(delete(MagicPlanWindow).where(col(MagicPlanWindow.magic_plan_room_id).in_(room_subq))) + session.execute(delete(MagicPlanDoor).where(col(MagicPlanDoor.magic_plan_room_id).in_(room_subq))) + session.execute(delete(MagicPlanRoom).where(col(MagicPlanRoom.magic_plan_floor_id).in_(floor_subq))) + session.execute(delete(MagicPlanFloor).where(col(MagicPlanFloor.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(MagicPlanFloor).values(rows).returning(col(MagicPlanFloor.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(MagicPlanRoom).values(rows).returning(col(MagicPlanRoom.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(MagicPlanWindow).values(window_rows)) + if door_rows: + session.execute(pg_insert(MagicPlanDoor).values(door_rows)) diff --git a/backend/app/db/functions/tests/test_magic_plan_functions.py b/backend/app/db/functions/tests/test_magic_plan_functions.py index c9785e26..42b42bba 100644 --- a/backend/app/db/functions/tests/test_magic_plan_functions.py +++ b/backend/app/db/functions/tests/test_magic_plan_functions.py @@ -101,24 +101,25 @@ def test_save_plan_deletes_floors_before_inserting(mock_session: MagicMock, doma assert floor_delete_idx < floor_insert_idx -def test_save_plan_inserts_correct_floor_count(mock_session: MagicMock, domain_plan: Plan) -> None: +def test_save_plan_floor_insert_contains_all_levels(mock_session: MagicMock, domain_plan: Plan) -> None: # Act save_plan(mock_session, domain_plan) - # Assert — floor INSERT contains values for both floors + # Assert — each floor's level value appears in the INSERT stmts = [_compiled(c[0][0]) for c in mock_session.execute.call_args_list] floor_insert = next(s for s in stmts if "INSERT" in s.upper() and "magic_plan_floor" in s) - # Each floor appears as a row — level values 0 and 1 from fixture - assert floor_insert.count("magic_plan_plan_id") >= len(domain_plan.floors) + for floor in domain_plan.floors: + if floor.level is not None: + assert str(floor.level) in floor_insert -def test_save_plan_inserts_correct_room_count(mock_session: MagicMock, domain_plan: Plan) -> None: +def test_save_plan_room_insert_uses_all_floor_ids(mock_session: MagicMock, domain_plan: Plan) -> None: # Act save_plan(mock_session, domain_plan) - # Assert — room INSERT contains values for all 14 rooms - total_rooms = sum(len(f.rooms) for f in domain_plan.floors) + # Assert — both mocked floor ids (10, 20) appear in the room INSERT stmts = [_compiled(c[0][0]) for c in mock_session.execute.call_args_list] room_insert = next(s for s in stmts if "INSERT" in s.upper() and "magic_plan_room" in s) - assert room_insert.count("magic_plan_floor_id") >= total_rooms + assert "10" in room_insert + assert "20" in room_insert def test_save_plan_windows_use_room_ids_from_insert(mock_session: MagicMock, domain_plan: Plan) -> None: From 1a8b27453e90df099d5c720d29578b9e120d0bdd Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 7 May 2026 13:11:18 +0000 Subject: [PATCH 13/19] =?UTF-8?q?MagicPlanService=20orchestrating=20fetch,?= =?UTF-8?q?=20match,=20map,=20persist=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tests/test_magic_plan_service.py | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 backend/magic_plan/tests/test_magic_plan_service.py diff --git a/backend/magic_plan/tests/test_magic_plan_service.py b/backend/magic_plan/tests/test_magic_plan_service.py new file mode 100644 index 00000000..8ae7fbda --- /dev/null +++ b/backend/magic_plan/tests/test_magic_plan_service.py @@ -0,0 +1,128 @@ +import json +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from datatypes.magicplan.api.response import MagicPlan, 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(MagicPlan.model_validate(data["data"])) + + +@pytest.fixture(scope="module") +def api_magic_plan() -> MagicPlan: + data = json.loads((FIXTURE_DIR / "magicplan_api_plan_response_example.json").read_text()) + return MagicPlan.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 MagicPlan.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: MagicPlan, + 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: MagicPlan, + 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: MagicPlan, + 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: MagicPlan, + 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") From 05bd2ba3e21ca85cf6c4540c34a2004ebb54ff0c Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 7 May 2026 13:12:00 +0000 Subject: [PATCH 14/19] =?UTF-8?q?MagicPlanService=20orchestrating=20fetch,?= =?UTF-8?q?=20match,=20map,=20persist=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/magic_plan/magic_plan_service.py | 35 ++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 backend/magic_plan/magic_plan_service.py diff --git a/backend/magic_plan/magic_plan_service.py b/backend/magic_plan/magic_plan_service.py new file mode 100644 index 00000000..422946e3 --- /dev/null +++ b/backend/magic_plan/magic_plan_service.py @@ -0,0 +1,35 @@ +from typing import Optional + +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 = self._client.get_plans() + matched = find_matching_plan(plans_response.plans, address) + + if matched is None: + raise ValueError(f"No MagicPlan found for address: {address!r}") + + magic_plan = self._client.get_plan(matched.id) + plan = map_plan(magic_plan) + + with db_session() as session: + save_plan(session, plan) + + return plan From 91a634e6377d1e4df2ca56d68ac2158df5d1c342 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 7 May 2026 13:16:08 +0000 Subject: [PATCH 15/19] =?UTF-8?q?Lambda=20handler=20with=20SQS=20trigger?= =?UTF-8?q?=20and=20=5F=5Fmain=5F=5F=20local=20runner=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/magic_plan/handler.py | 28 ++++++ backend/magic_plan/tests/test_handler.py | 103 +++++++++++++++++++++++ 2 files changed, 131 insertions(+) create mode 100644 backend/magic_plan/handler.py create mode 100644 backend/magic_plan/tests/test_handler.py diff --git a/backend/magic_plan/handler.py b/backend/magic_plan/handler.py new file mode 100644 index 00000000..2629c810 --- /dev/null +++ b/backend/magic_plan/handler.py @@ -0,0 +1,28 @@ +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 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 = 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) diff --git a/backend/magic_plan/tests/test_handler.py b/backend/magic_plan/tests/test_handler.py new file mode 100644 index 00000000..366f3ded --- /dev/null +++ b/backend/magic_plan/tests/test_handler.py @@ -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 From 6b29086a1e2c42d66a1fcf3766a7e125b8859d13 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 7 May 2026 13:26:49 +0000 Subject: [PATCH 16/19] =?UTF-8?q?typing=20and=20renaming=20=F0=9F=9F=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/db/functions/magic_plan_functions.py | 79 +++++++---- .../tests/test_magic_plan_functions.py | 86 +++++++---- backend/app/db/models/magic_plan.py | 10 +- .../db/models/tests/test_magic_plan_models.py | 134 ++++++++++++++++++ backend/magic_plan/address_matcher.py | 7 +- backend/magic_plan/magic_plan_client.py | 10 +- backend/magic_plan/magic_plan_service.py | 15 +- .../tests/test_magic_plan_client.py | 79 ++++++++--- .../tests/test_magic_plan_service.py | 66 +++++---- datatypes/magicplan/api/response.py | 3 +- .../magicplan/api/tests/test_response.py | 34 +++-- datatypes/magicplan/domain/mapper.py | 10 +- .../magicplan/domain/tests/test_mapper.py | 14 +- 13 files changed, 405 insertions(+), 142 deletions(-) create mode 100644 backend/app/db/models/tests/test_magic_plan_models.py diff --git a/backend/app/db/functions/magic_plan_functions.py b/backend/app/db/functions/magic_plan_functions.py index 129ec958..9400f36f 100644 --- a/backend/app/db/functions/magic_plan_functions.py +++ b/backend/app/db/functions/magic_plan_functions.py @@ -6,25 +6,25 @@ from sqlmodel import Session, col from datatypes.magicplan.domain.models import Floor, Plan from backend.app.db.models.magic_plan import ( - MagicPlanDoor, - MagicPlanFloor, - MagicPlanPlan, - MagicPlanRoom, - MagicPlanWindow, + MagicPlanDoorModel, + MagicPlanFloorModel, + MagicPlanPlanModel, + MagicPlanRoomModel, + MagicPlanWindowModel, ) def save_plan(session: Session, plan: Plan) -> None: - plan_id = _upsert_plan(session, plan) + plan_id: int = _upsert_plan(session, plan) _delete_children(session, plan_id) - floor_ids = _insert_floors(session, plan.floors, plan_id) - room_ids = _insert_rooms(session, plan.floors, floor_ids) + 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(MagicPlanPlan) + pg_insert(MagicPlanPlanModel) .values( magic_plan_uid=plan.uid, name=plan.name, @@ -33,9 +33,13 @@ def _upsert_plan(session: Session, plan: Plan) -> int: ) .on_conflict_do_update( index_elements=["magic_plan_uid"], - set_={"name": plan.name, "address": plan.address, "postcode": plan.postcode}, + set_={ + "name": plan.name, + "address": plan.address, + "postcode": plan.postcode, + }, ) - .returning(col(MagicPlanPlan.id)) + .returning(col(MagicPlanPlanModel.id)) ) row_id: int = session.execute(stmt).scalar_one() return row_id @@ -43,33 +47,52 @@ def _upsert_plan(session: Session, plan: Plan) -> int: def _delete_children(session: Session, plan_id: int) -> None: floor_subq = ( - select(col(MagicPlanFloor.id)) - .where(col(MagicPlanFloor.magic_plan_plan_id) == plan_id) + select(col(MagicPlanFloorModel.id)) + .where(col(MagicPlanFloorModel.magic_plan_plan_id) == plan_id) .scalar_subquery() ) room_subq = ( - select(col(MagicPlanRoom.id)) - .where(col(MagicPlanRoom.magic_plan_floor_id).in_(floor_subq)) + select(col(MagicPlanRoomModel.id)) + .where(col(MagicPlanRoomModel.magic_plan_floor_id).in_(floor_subq)) .scalar_subquery() ) - session.execute(delete(MagicPlanWindow).where(col(MagicPlanWindow.magic_plan_room_id).in_(room_subq))) - session.execute(delete(MagicPlanDoor).where(col(MagicPlanDoor.magic_plan_room_id).in_(room_subq))) - session.execute(delete(MagicPlanRoom).where(col(MagicPlanRoom.magic_plan_floor_id).in_(floor_subq))) - session.execute(delete(MagicPlanFloor).where(col(MagicPlanFloor.magic_plan_plan_id) == plan_id)) + 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 + {"magic_plan_plan_id": plan_id, "level": floor.level} for floor in floors ] result = session.execute( - pg_insert(MagicPlanFloor).values(rows).returning(col(MagicPlanFloor.id)) + 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]: +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, @@ -82,12 +105,14 @@ def _insert_rooms(session: Session, floors: list[Floor], floor_ids: list[int]) - for room in floor.rooms ] result = session.execute( - pg_insert(MagicPlanRoom).values(rows).returning(col(MagicPlanRoom.id)) + 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: +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]] = [ @@ -111,6 +136,6 @@ def _insert_windows_and_doors(session: Session, floors: list[Floor], room_ids: l ] if window_rows: - session.execute(pg_insert(MagicPlanWindow).values(window_rows)) + session.execute(pg_insert(MagicPlanWindowModel).values(window_rows)) if door_rows: - session.execute(pg_insert(MagicPlanDoor).values(door_rows)) + session.execute(pg_insert(MagicPlanDoorModel).values(door_rows)) diff --git a/backend/app/db/functions/tests/test_magic_plan_functions.py b/backend/app/db/functions/tests/test_magic_plan_functions.py index 42b42bba..2d7cb835 100644 --- a/backend/app/db/functions/tests/test_magic_plan_functions.py +++ b/backend/app/db/functions/tests/test_magic_plan_functions.py @@ -5,7 +5,7 @@ from unittest.mock import MagicMock import pytest from sqlalchemy.dialects import postgresql -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.models import Plan @@ -19,15 +19,19 @@ PLAN_UID = "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(MagicPlan.model_validate(data["data"])) + data = json.loads( + (FIXTURE_DIR / "magicplan_api_plan_response_example.json").read_text() + ) + return map_plan(MagicPlanPlan.model_validate(data["data"])) def _compiled(stmt: object) -> str: - return str(stmt.compile( # type: ignore[union-attr] - dialect=postgresql.dialect(), - compile_kwargs={"literal_binds": True}, - )) + return str( + stmt.compile( # type: ignore[union-attr] + dialect=postgresql.dialect(), + compile_kwargs={"literal_binds": True}, + ) + ) @pytest.fixture() @@ -44,15 +48,15 @@ def mock_session() -> MagicMock: room_result.scalars.return_value.all.return_value = list(range(100, 114)) session.execute.side_effect = [ - plan_result, # upsert plan - None, # delete windows - None, # delete doors - None, # delete rooms - None, # delete floors + plan_result, # upsert plan + None, # delete windows + None, # delete doors + None, # delete rooms + None, # delete floors floor_result, # insert floors - room_result, # insert rooms - None, # insert windows - None, # insert doors + room_result, # insert rooms + None, # insert windows + None, # insert doors ] return session @@ -65,7 +69,9 @@ def test_save_plan_does_not_raise(mock_session: MagicMock, domain_plan: Plan) -> save_plan(mock_session, domain_plan) -def test_save_plan_upserts_plan_table_first(mock_session: MagicMock, domain_plan: Plan) -> None: +def test_save_plan_upserts_plan_table_first( + mock_session: MagicMock, domain_plan: Plan +) -> None: # Act save_plan(mock_session, domain_plan) # Assert @@ -75,7 +81,9 @@ def test_save_plan_upserts_plan_table_first(mock_session: MagicMock, domain_plan assert "INSERT" in sql.upper() -def test_save_plan_upsert_contains_plan_uid(mock_session: MagicMock, domain_plan: Plan) -> None: +def test_save_plan_upsert_contains_plan_uid( + mock_session: MagicMock, domain_plan: Plan +) -> None: # Act save_plan(mock_session, domain_plan) # Assert @@ -83,7 +91,9 @@ def test_save_plan_upsert_contains_plan_uid(mock_session: MagicMock, domain_plan assert PLAN_UID in _compiled(first_stmt) -def test_save_plan_upsert_contains_plan_name(mock_session: MagicMock, domain_plan: Plan) -> None: +def test_save_plan_upsert_contains_plan_name( + mock_session: MagicMock, domain_plan: Plan +) -> None: # Act save_plan(mock_session, domain_plan) # Assert @@ -91,41 +101,63 @@ def test_save_plan_upsert_contains_plan_name(mock_session: MagicMock, domain_pla assert domain_plan.name in _compiled(first_stmt) -def test_save_plan_deletes_floors_before_inserting(mock_session: MagicMock, domain_plan: Plan) -> None: +def test_save_plan_deletes_floors_before_inserting( + mock_session: MagicMock, domain_plan: Plan +) -> None: # Act save_plan(mock_session, domain_plan) # Assert — find delete and insert stmts targeting magic_plan_floor stmts = [_compiled(c[0][0]) for c in mock_session.execute.call_args_list] - floor_delete_idx = next(i for i, s in enumerate(stmts) if "DELETE" in s.upper() and "magic_plan_floor" in s) - floor_insert_idx = next(i for i, s in enumerate(stmts) if "INSERT" in s.upper() and "magic_plan_floor" in s) + floor_delete_idx = next( + i + for i, s in enumerate(stmts) + if "DELETE" in s.upper() and "magic_plan_floor" in s + ) + floor_insert_idx = next( + i + for i, s in enumerate(stmts) + if "INSERT" in s.upper() and "magic_plan_floor" in s + ) assert floor_delete_idx < floor_insert_idx -def test_save_plan_floor_insert_contains_all_levels(mock_session: MagicMock, domain_plan: Plan) -> None: +def test_save_plan_floor_insert_contains_all_levels( + mock_session: MagicMock, domain_plan: Plan +) -> None: # Act save_plan(mock_session, domain_plan) # Assert — each floor's level value appears in the INSERT stmts = [_compiled(c[0][0]) for c in mock_session.execute.call_args_list] - floor_insert = next(s for s in stmts if "INSERT" in s.upper() and "magic_plan_floor" in s) + floor_insert = next( + s for s in stmts if "INSERT" in s.upper() and "magic_plan_floor" in s + ) for floor in domain_plan.floors: if floor.level is not None: assert str(floor.level) in floor_insert -def test_save_plan_room_insert_uses_all_floor_ids(mock_session: MagicMock, domain_plan: Plan) -> None: +def test_save_plan_room_insert_uses_all_floor_ids( + mock_session: MagicMock, domain_plan: Plan +) -> None: # Act save_plan(mock_session, domain_plan) # Assert — both mocked floor ids (10, 20) appear in the room INSERT stmts = [_compiled(c[0][0]) for c in mock_session.execute.call_args_list] - room_insert = next(s for s in stmts if "INSERT" in s.upper() and "magic_plan_room" in s) + room_insert = next( + s for s in stmts if "INSERT" in s.upper() and "magic_plan_room" in s + ) assert "10" in room_insert assert "20" in room_insert -def test_save_plan_windows_use_room_ids_from_insert(mock_session: MagicMock, domain_plan: Plan) -> None: +def test_save_plan_windows_use_room_ids_from_insert( + mock_session: MagicMock, domain_plan: Plan +) -> None: # Act save_plan(mock_session, domain_plan) # Assert — window INSERT references one of the mocked room ids (100–113) stmts = [_compiled(c[0][0]) for c in mock_session.execute.call_args_list] - window_insert = next(s for s in stmts if "INSERT" in s.upper() and "magic_plan_window" in s) + window_insert = next( + s for s in stmts if "INSERT" in s.upper() and "magic_plan_window" in s + ) assert any(str(rid) in window_insert for rid in range(100, 114)) diff --git a/backend/app/db/models/magic_plan.py b/backend/app/db/models/magic_plan.py index 8a4ecd55..38e9de18 100644 --- a/backend/app/db/models/magic_plan.py +++ b/backend/app/db/models/magic_plan.py @@ -3,7 +3,7 @@ from typing import Optional from sqlmodel import Field, SQLModel -class MagicPlanPlan(SQLModel, table=True): +class MagicPlanPlanModel(SQLModel, table=True): __tablename__ = "magic_plan_plan" id: Optional[int] = Field(default=None, primary_key=True) @@ -13,7 +13,7 @@ class MagicPlanPlan(SQLModel, table=True): postcode: Optional[str] = None -class MagicPlanFloor(SQLModel, table=True): +class MagicPlanFloorModel(SQLModel, table=True): __tablename__ = "magic_plan_floor" id: Optional[int] = Field(default=None, primary_key=True) @@ -21,7 +21,7 @@ class MagicPlanFloor(SQLModel, table=True): level: Optional[int] = None -class MagicPlanRoom(SQLModel, table=True): +class MagicPlanRoomModel(SQLModel, table=True): __tablename__ = "magic_plan_room" id: Optional[int] = Field(default=None, primary_key=True) @@ -32,7 +32,7 @@ class MagicPlanRoom(SQLModel, table=True): area_m2: Optional[float] = None -class MagicPlanWindow(SQLModel, table=True): +class MagicPlanWindowModel(SQLModel, table=True): __tablename__ = "magic_plan_window" id: Optional[int] = Field(default=None, primary_key=True) @@ -43,7 +43,7 @@ class MagicPlanWindow(SQLModel, table=True): opening_type: Optional[str] = None -class MagicPlanDoor(SQLModel, table=True): +class MagicPlanDoorModel(SQLModel, table=True): __tablename__ = "magic_plan_door" id: Optional[int] = Field(default=None, primary_key=True) diff --git a/backend/app/db/models/tests/test_magic_plan_models.py b/backend/app/db/models/tests/test_magic_plan_models.py new file mode 100644 index 00000000..0830b184 --- /dev/null +++ b/backend/app/db/models/tests/test_magic_plan_models.py @@ -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" diff --git a/backend/magic_plan/address_matcher.py b/backend/magic_plan/address_matcher.py index 043358c0..3477c535 100644 --- a/backend/magic_plan/address_matcher.py +++ b/backend/magic_plan/address_matcher.py @@ -1,10 +1,9 @@ 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 -) +_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: @@ -18,7 +17,7 @@ def _normalize_postcode(postcode: str) -> str: return postcode.replace(" ", "").upper() -def find_matching_plan(plans: list[PlanSummary], address: str) -> PlanSummary | None: +def find_matching_plan(plans: list[PlanSummary], address: str) -> Optional[PlanSummary]: postcode = _extract_postcode(address) if postcode is None: return None diff --git a/backend/magic_plan/magic_plan_client.py b/backend/magic_plan/magic_plan_client.py index ff99062d..60f70fb1 100644 --- a/backend/magic_plan/magic_plan_client.py +++ b/backend/magic_plan/magic_plan_client.py @@ -1,6 +1,6 @@ import requests -from datatypes.magicplan.api.response import MagicPlan, PlansListResponse +from datatypes.magicplan.api.response import MagicPlanPlan, PlansListResponse _BASE_URL = "https://cloud.magicplan.app/api/v2" @@ -16,7 +16,9 @@ class MagicPlanClient: r.raise_for_status() return PlansListResponse.model_validate(r.json()["data"]) - def get_plan(self, plan_id: str) -> MagicPlan: - r = self._session.get(f"{_BASE_URL}/plans/{plan_id}", params={"key": self._api_key}) + 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 MagicPlan.model_validate(r.json()["data"]) + return MagicPlanPlan.model_validate(r.json()["data"]) diff --git a/backend/magic_plan/magic_plan_service.py b/backend/magic_plan/magic_plan_service.py index 422946e3..600d2e17 100644 --- a/backend/magic_plan/magic_plan_service.py +++ b/backend/magic_plan/magic_plan_service.py @@ -1,5 +1,10 @@ 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 @@ -20,14 +25,16 @@ class MagicPlanService: if uprn is not None: logger.info("MagicPlanService.run uprn=%s", uprn) - plans_response = self._client.get_plans() - matched = find_matching_plan(plans_response.plans, address) + plans_response: PlansListResponse = self._client.get_plans() + matched: Optional[PlanSummary] = find_matching_plan( + plans_response.plans, address + ) if matched is None: raise ValueError(f"No MagicPlan found for address: {address!r}") - magic_plan = self._client.get_plan(matched.id) - plan = map_plan(magic_plan) + magic_plan: MagicPlanPlan = self._client.get_plan(matched.id) + plan: Plan = map_plan(magic_plan) with db_session() as session: save_plan(session, plan) diff --git a/backend/magic_plan/tests/test_magic_plan_client.py b/backend/magic_plan/tests/test_magic_plan_client.py index 02ae054c..1be1448f 100644 --- a/backend/magic_plan/tests/test_magic_plan_client.py +++ b/backend/magic_plan/tests/test_magic_plan_client.py @@ -7,7 +7,7 @@ import pytest import requests from backend.magic_plan.magic_plan_client import MagicPlanClient -from datatypes.magicplan.api.response import MagicPlan, PlansListResponse +from datatypes.magicplan.api.response import MagicPlanPlan, PlansListResponse FIXTURE_DIR = Path(__file__).parents[2] / "magic_plan" BASE_URL = "https://cloud.magicplan.app/api/v2" @@ -20,7 +20,10 @@ def _load_fixture(name: str) -> dict[str, Any]: def _make_client(mock_session: MagicMock) -> MagicPlanClient: - with patch("backend.magic_plan.magic_plan_client.requests.Session", return_value=mock_session): + with patch( + "backend.magic_plan.magic_plan_client.requests.Session", + return_value=mock_session, + ): return MagicPlanClient(customer_id=CUSTOMER_ID, api_key=API_KEY) @@ -47,10 +50,15 @@ def test_customer_header_set_on_session(mock_session: MagicMock) -> None: # --- get_plans --- -def test_get_plans_calls_correct_url(client: MagicPlanClient, mock_session: MagicMock) -> None: +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} + mock_session.get.return_value.json.return_value = { + "message": "OK", + "data": plans_data, + } # Act client.get_plans() # Assert @@ -59,20 +67,30 @@ def test_get_plans_calls_correct_url(client: MagicPlanClient, mock_session: Magi ) -def test_get_plans_calls_raise_for_status(client: MagicPlanClient, mock_session: MagicMock) -> None: +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} + 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: +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} + mock_session.get.return_value.json.return_value = { + "message": "OK", + "data": plans_data, + } # Act result = client.get_plans() # Assert @@ -80,9 +98,13 @@ def test_get_plans_returns_plans_list_response(client: MagicPlanClient, mock_ses assert len(result.plans) == 1 -def test_get_plans_propagates_http_error(client: MagicPlanClient, mock_session: MagicMock) -> None: +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") + mock_session.get.return_value.raise_for_status.side_effect = requests.HTTPError( + "404" + ) # Act / Assert with pytest.raises(requests.HTTPError): client.get_plans() @@ -91,10 +113,15 @@ def test_get_plans_propagates_http_error(client: MagicPlanClient, mock_session: # --- get_plan --- -def test_get_plan_calls_correct_url(client: MagicPlanClient, mock_session: MagicMock) -> None: +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} + 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) @@ -104,30 +131,44 @@ def test_get_plan_calls_correct_url(client: MagicPlanClient, mock_session: Magic ) -def test_get_plan_calls_raise_for_status(client: MagicPlanClient, mock_session: MagicMock) -> None: +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} + 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: +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} + 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, MagicPlan) + 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: +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") + 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") diff --git a/backend/magic_plan/tests/test_magic_plan_service.py b/backend/magic_plan/tests/test_magic_plan_service.py index 8ae7fbda..8e433b87 100644 --- a/backend/magic_plan/tests/test_magic_plan_service.py +++ b/backend/magic_plan/tests/test_magic_plan_service.py @@ -4,7 +4,7 @@ from unittest.mock import MagicMock, patch import pytest -from datatypes.magicplan.api.response import MagicPlan, PlanSummary +from datatypes.magicplan.api.response import MagicPlanPlan, PlanSummary from datatypes.magicplan.domain.mapper import map_plan from datatypes.magicplan.domain.models import Plan @@ -17,20 +17,26 @@ 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(MagicPlan.model_validate(data["data"])) + 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() -> MagicPlan: - data = json.loads((FIXTURE_DIR / "magicplan_api_plan_response_example.json").read_text()) - return MagicPlan.model_validate(data["data"]) +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 MagicPlan.model_validate(data["data"]).plan + data = json.loads( + (FIXTURE_DIR / "magicplan_api_plan_response_example.json").read_text() + ) + return MagicPlanPlan.model_validate(data["data"]).plan @pytest.fixture() @@ -59,7 +65,7 @@ def test_run_raises_when_no_plan_found(mock_client: MagicMock) -> None: def test_run_fetches_plan_with_matched_id( mock_client: MagicMock, - api_magic_plan: MagicPlan, + api_magic_plan: MagicPlanPlan, plan_summary: PlanSummary, domain_plan: Plan, ) -> None: @@ -67,9 +73,12 @@ def test_run_fetches_plan_with_matched_id( 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"): + 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) @@ -77,7 +86,7 @@ def test_run_fetches_plan_with_matched_id( def test_run_returns_mapped_plan( mock_client: MagicMock, - api_magic_plan: MagicPlan, + api_magic_plan: MagicPlanPlan, plan_summary: PlanSummary, domain_plan: Plan, ) -> None: @@ -85,9 +94,12 @@ def test_run_returns_mapped_plan( 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"): + 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) @@ -96,16 +108,19 @@ def test_run_returns_mapped_plan( def test_run_calls_save_plan_with_mapped_plan( mock_client: MagicMock, - api_magic_plan: MagicPlan, + 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"): + 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 @@ -115,14 +130,17 @@ def test_run_calls_save_plan_with_mapped_plan( def test_run_accepts_uprn_without_error( mock_client: MagicMock, - api_magic_plan: MagicPlan, + 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"): + 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") diff --git a/datatypes/magicplan/api/response.py b/datatypes/magicplan/api/response.py index 2fc3738d..69801128 100644 --- a/datatypes/magicplan/api/response.py +++ b/datatypes/magicplan/api/response.py @@ -2,7 +2,6 @@ from typing import Any, Optional, Union from pydantic import BaseModel, ConfigDict, Field - _IGNORE = ConfigDict(extra="ignore") _IGNORE_POPULATE = ConfigDict(extra="ignore", populate_by_name=True) @@ -287,7 +286,7 @@ class PlansListResponse(BaseModel): plans: list[PlanSummary] = [] -class MagicPlan(BaseModel): +class MagicPlanPlan(BaseModel): model_config = _IGNORE plan: PlanSummary plan_detail: PlanDetail diff --git a/datatypes/magicplan/api/tests/test_response.py b/datatypes/magicplan/api/tests/test_response.py index 469b7f14..a4d966dd 100644 --- a/datatypes/magicplan/api/tests/test_response.py +++ b/datatypes/magicplan/api/tests/test_response.py @@ -4,7 +4,7 @@ from typing import Any import pytest -from datatypes.magicplan.api.response import MagicPlan, PlansListResponse +from datatypes.magicplan.api.response import MagicPlanPlan, PlansListResponse FIXTURE_DIR = Path(__file__).parents[4] / "backend" / "magic_plan" PLAN_ID = "a7285ed1-878d-47eb-8aa6-85ef9e187516" @@ -19,51 +19,51 @@ def raw_data() -> dict[str, Any]: @pytest.fixture(scope="module") -def mp(raw_data: dict[str, Any]) -> MagicPlan: - return MagicPlan.model_validate(raw_data) +def mp(raw_data: dict[str, Any]) -> MagicPlanPlan: + return MagicPlanPlan.model_validate(raw_data) def test_model_validate_does_not_raise(raw_data: dict[str, Any]): # 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 mp.plan.id == PLAN_ID -def test_url_3d_alias(mp: MagicPlan): +def test_url_3d_alias(mp: MagicPlanPlan): # assert assert mp.plan.url_3d is not None assert mp.plan.url_3d.startswith("http") -def test_floor_count(mp: MagicPlan): +def test_floor_count(mp: MagicPlanPlan): # assert assert len(mp.plan_detail.plan.floors) == 2 -def test_first_room_name(mp: MagicPlan): +def test_first_room_name(mp: MagicPlanPlan): # assert 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 room = mp.plan_detail.plan.floors[0].rooms[0] # assert assert isinstance(room.area, float) -def test_wall_item_symbol_id(mp: MagicPlan): +def test_wall_item_symbol_id(mp: MagicPlanPlan): # arrange room = mp.plan_detail.plan.floors[0].rooms[0] # assert assert room.wall_items[0].symbol.id != "" -def test_field_value_array(mp: MagicPlan): +def test_field_value_array(mp: MagicPlanPlan): # arrange room = mp.plan_detail.plan.floors[0].rooms[0] 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) -def test_field_value_scalar(mp: MagicPlan): +def test_field_value_scalar(mp: MagicPlanPlan): # arrange 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) @@ -83,7 +83,7 @@ def test_extra_fields_ignored(raw_data: dict[str, Any]): # arrange data_with_extra = {**raw_data, "unknown_future_field": "whatever"} # act - MagicPlan.model_validate(data_with_extra) + MagicPlanPlan.model_validate(data_with_extra) # --- PlansListResponse --- @@ -102,7 +102,9 @@ 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: +def test_plans_list_model_validate_does_not_raise( + plans_raw_data: dict[str, Any], +) -> None: # act PlansListResponse.model_validate(plans_raw_data) @@ -122,7 +124,9 @@ def test_plans_list_paging_page(plans_response: PlansListResponse) -> None: assert plans_response.paging.page == 1 -def test_plans_list_paging_next_page_is_false(plans_response: PlansListResponse) -> None: +def test_plans_list_paging_next_page_is_false( + plans_response: PlansListResponse, +) -> None: # assert assert plans_response.paging.next_page is False diff --git a/datatypes/magicplan/domain/mapper.py b/datatypes/magicplan/domain/mapper.py index fc525d5c..1804e58e 100644 --- a/datatypes/magicplan/domain/mapper.py +++ b/datatypes/magicplan/domain/mapper.py @@ -1,9 +1,11 @@ +from typing import Optional + 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 -def map_plan(mp: MagicPlan) -> Plan: +def map_plan(mp: MagicPlanPlan) -> Plan: return Plan( uid=mp.plan.id, 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: return 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: return 0.0, 0.0 parts = dimensions.split(" x ") diff --git a/datatypes/magicplan/domain/tests/test_mapper.py b/datatypes/magicplan/domain/tests/test_mapper.py index 2e5bcf47..78977939 100644 --- a/datatypes/magicplan/domain/tests/test_mapper.py +++ b/datatypes/magicplan/domain/tests/test_mapper.py @@ -4,7 +4,7 @@ from typing import Any 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.models import Plan @@ -22,12 +22,12 @@ def raw_data() -> dict[str, Any]: @pytest.fixture(scope="module") -def mp(raw_data: dict[str, Any]) -> MagicPlan: - return MagicPlan.model_validate(raw_data) +def mp(raw_data: dict[str, Any]) -> MagicPlanPlan: + return MagicPlanPlan.model_validate(raw_data) @pytest.fixture(scope="module") -def plan(mp: MagicPlan) -> Plan: +def plan(mp: MagicPlanPlan) -> Plan: return map_plan(mp) @@ -119,7 +119,7 @@ def raw_data_2() -> dict[str, Any]: @pytest.fixture(scope="module") 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): @@ -201,7 +201,7 @@ def plan3() -> Plan: payload = json.loads( (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): @@ -220,7 +220,7 @@ def plan4() -> Plan: payload = json.loads( (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): From f56dba4ad1a51b3ce936d42e2931c59800026398 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 8 May 2026 10:13:44 +0000 Subject: [PATCH 17/19] use pytest-postgresql in db tests instead of mocking and checking sql strings --- backend/app/db/functions/tests/conftest.py | 41 +++++ .../tests/test_magic_plan_functions.py | 172 ++++++------------ pytest.ini | 2 +- 3 files changed, 94 insertions(+), 121 deletions(-) create mode 100644 backend/app/db/functions/tests/conftest.py diff --git a/backend/app/db/functions/tests/conftest.py b/backend/app/db/functions/tests/conftest.py new file mode 100644 index 00000000..3f97e92b --- /dev/null +++ b/backend/app/db/functions/tests/conftest.py @@ -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() diff --git a/backend/app/db/functions/tests/test_magic_plan_functions.py b/backend/app/db/functions/tests/test_magic_plan_functions.py index 2d7cb835..e58d0528 100644 --- a/backend/app/db/functions/tests/test_magic_plan_functions.py +++ b/backend/app/db/functions/tests/test_magic_plan_functions.py @@ -1,20 +1,25 @@ import json from pathlib import Path -from unittest.mock import MagicMock import pytest -from sqlalchemy.dialects import postgresql +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" -PLAN_UID = "a7285ed1-878d-47eb-8aa6-85ef9e187516" - -# fixture 1: 2 floors, 14 rooms total, 13 windows, 27 doors @pytest.fixture(scope="module") @@ -25,139 +30,66 @@ def domain_plan() -> Plan: return map_plan(MagicPlanPlan.model_validate(data["data"])) -def _compiled(stmt: object) -> str: - return str( - stmt.compile( # type: ignore[union-attr] - dialect=postgresql.dialect(), - compile_kwargs={"literal_binds": True}, - ) - ) +def _count(session: Session, model: type[SQLModel]) -> int: + return session.execute(select(func.count()).select_from(model)).scalar_one() -@pytest.fixture() -def mock_session() -> MagicMock: - session = MagicMock() - - plan_result = MagicMock() - plan_result.scalar_one.return_value = 1 - - floor_result = MagicMock() - floor_result.scalars.return_value.all.return_value = [10, 20] - - room_result = MagicMock() - room_result.scalars.return_value.all.return_value = list(range(100, 114)) - - session.execute.side_effect = [ - plan_result, # upsert plan - None, # delete windows - None, # delete doors - None, # delete rooms - None, # delete floors - floor_result, # insert floors - room_result, # insert rooms - None, # insert windows - None, # insert doors - ] - return session - - -# --- save_plan orchestration --- - - -def test_save_plan_does_not_raise(mock_session: MagicMock, domain_plan: Plan) -> None: +def test_plan_row_present_after_save(db_session: Session, domain_plan: Plan) -> None: # Act - save_plan(mock_session, domain_plan) - - -def test_save_plan_upserts_plan_table_first( - mock_session: MagicMock, domain_plan: Plan -) -> None: - # Act - save_plan(mock_session, domain_plan) + save_plan(db_session, domain_plan) # Assert - first_stmt = mock_session.execute.call_args_list[0][0][0] - sql = _compiled(first_stmt) - assert "magic_plan_plan" in sql - assert "INSERT" in sql.upper() + assert _count(db_session, MagicPlanPlanModel) == 1 -def test_save_plan_upsert_contains_plan_uid( - mock_session: MagicMock, domain_plan: Plan -) -> None: +def test_floor_count_matches_domain(db_session: Session, domain_plan: Plan) -> None: + # Arrange + expected = len(domain_plan.floors) # Act - save_plan(mock_session, domain_plan) + save_plan(db_session, domain_plan) # Assert - first_stmt = mock_session.execute.call_args_list[0][0][0] - assert PLAN_UID in _compiled(first_stmt) + assert _count(db_session, MagicPlanFloorModel) == expected -def test_save_plan_upsert_contains_plan_name( - mock_session: MagicMock, domain_plan: Plan -) -> None: +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(mock_session, domain_plan) + save_plan(db_session, domain_plan) # Assert - first_stmt = mock_session.execute.call_args_list[0][0][0] - assert domain_plan.name in _compiled(first_stmt) + assert _count(db_session, MagicPlanRoomModel) == expected -def test_save_plan_deletes_floors_before_inserting( - mock_session: MagicMock, domain_plan: Plan -) -> None: +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(mock_session, domain_plan) - # Assert — find delete and insert stmts targeting magic_plan_floor - stmts = [_compiled(c[0][0]) for c in mock_session.execute.call_args_list] - floor_delete_idx = next( - i - for i, s in enumerate(stmts) - if "DELETE" in s.upper() and "magic_plan_floor" in s - ) - floor_insert_idx = next( - i - for i, s in enumerate(stmts) - if "INSERT" in s.upper() and "magic_plan_floor" in s - ) - assert floor_delete_idx < floor_insert_idx + save_plan(db_session, domain_plan) + # Assert + assert _count(db_session, MagicPlanWindowModel) == expected -def test_save_plan_floor_insert_contains_all_levels( - mock_session: MagicMock, domain_plan: Plan -) -> None: +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(mock_session, domain_plan) - # Assert — each floor's level value appears in the INSERT - stmts = [_compiled(c[0][0]) for c in mock_session.execute.call_args_list] - floor_insert = next( - s for s in stmts if "INSERT" in s.upper() and "magic_plan_floor" in s + 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 ) - for floor in domain_plan.floors: - if floor.level is not None: - assert str(floor.level) in floor_insert - - -def test_save_plan_room_insert_uses_all_floor_ids( - mock_session: MagicMock, domain_plan: Plan -) -> None: - # Act - save_plan(mock_session, domain_plan) - # Assert — both mocked floor ids (10, 20) appear in the room INSERT - stmts = [_compiled(c[0][0]) for c in mock_session.execute.call_args_list] - room_insert = next( - s for s in stmts if "INSERT" in s.upper() and "magic_plan_room" in s + assert _count(db_session, MagicPlanWindowModel) == sum( + len(r.windows) for f in domain_plan.floors for r in f.rooms ) - assert "10" in room_insert - assert "20" in room_insert - - -def test_save_plan_windows_use_room_ids_from_insert( - mock_session: MagicMock, domain_plan: Plan -) -> None: - # Act - save_plan(mock_session, domain_plan) - # Assert — window INSERT references one of the mocked room ids (100–113) - stmts = [_compiled(c[0][0]) for c in mock_session.execute.call_args_list] - window_insert = next( - s for s in stmts if "INSERT" in s.upper() and "magic_plan_window" in s + assert _count(db_session, MagicPlanDoorModel) == sum( + len(r.doors) for f in domain_plan.floors for r in f.rooms ) - assert any(str(rid) in window_insert for rid in range(100, 114)) diff --git a/pytest.ini b/pytest.ini index 761dfbed..398c5b71 100644 --- a/pytest.ini +++ b/pytest.ini @@ -3,6 +3,6 @@ pythonpath = . log_cli = true log_cli_level = INFO 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 = integration: mark a test as an integration test From c5408f3a89b506bda2104e18f6912d908acdd8e7 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 8 May 2026 10:18:12 +0000 Subject: [PATCH 18/19] typing --- backend/magic_plan/handler.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/backend/magic_plan/handler.py b/backend/magic_plan/handler.py index 2629c810..a592cc6a 100644 --- a/backend/magic_plan/handler.py +++ b/backend/magic_plan/handler.py @@ -4,6 +4,7 @@ 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 @@ -18,11 +19,18 @@ def handler(body: dict[str, Any], context: Any) -> str: customer_id=settings.MAGICPLAN_CUSTOMER_ID, api_key=settings.MAGICPLAN_API_KEY, ) - plan = MagicPlanService(client).run(payload.address, payload.uprn) + 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"}]} + event = { + "Records": [ + { + "body": '{"address": "2 Laburnum Way Bromley BR2 8BZ"}', + "messageId": "local-test", + } + ] + } handler(event, None) From f903fdefe7cb7aa74b9d36276164da73695eee54 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 8 May 2026 10:20:12 +0000 Subject: [PATCH 19/19] add TODO --- backend/magic_plan/magic_plan_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/magic_plan/magic_plan_service.py b/backend/magic_plan/magic_plan_service.py index 600d2e17..91b3cd13 100644 --- a/backend/magic_plan/magic_plan_service.py +++ b/backend/magic_plan/magic_plan_service.py @@ -28,7 +28,7 @@ class MagicPlanService: 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}")