From 3ba9a310abccae00ee5d6dd5eacc6ce2c55b3466 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 28 Apr 2026 15:24:17 +0000 Subject: [PATCH] start defining magic plan client --- backend/magic_plan/magic_plan_client.py | 14 ++ backend/magic_plan/models.py | 25 +++ .../tests/test_magic_plan_client.py | 195 ++++++++++++++++++ 3 files changed, 234 insertions(+) create mode 100644 backend/magic_plan/tests/test_magic_plan_client.py diff --git a/backend/magic_plan/magic_plan_client.py b/backend/magic_plan/magic_plan_client.py index e69de29b..29ad078a 100644 --- a/backend/magic_plan/magic_plan_client.py +++ b/backend/magic_plan/magic_plan_client.py @@ -0,0 +1,14 @@ +from backend.magic_plan.models import MagicPlanDetail, MagicPlanSummary + + +class MagicPlanClient: + BASE_URL = "https://cloud.magicplan.app/api/v2" + + def __init__(self, _api_key: str) -> None: + raise NotImplementedError + + def get_plans(self, _project_id: str) -> list[MagicPlanSummary]: + raise NotImplementedError + + def get_plan_xml(self, _plan_id: str) -> MagicPlanDetail: + raise NotImplementedError diff --git a/backend/magic_plan/models.py b/backend/magic_plan/models.py index 46cf0fef..5bb5e413 100644 --- a/backend/magic_plan/models.py +++ b/backend/magic_plan/models.py @@ -171,6 +171,31 @@ class MagicPlanFloor: exploded: MagicPlanExploded +@dataclass +class MagicPlanSummary: + """Plan metadata returned by the list-plans API endpoint.""" + id: str + project_id: str + name: str + address: str + creation_date: str + update_date: str + workgroup_id: str + team_id: str + created_by: str + thumbnail_url: str + public_url: str + cloud_url: str + url_3d: str + + +@dataclass +class MagicPlanDetail: + """Full plan response: summary metadata + parsed XML plan.""" + summary: MagicPlanSummary + plan_xml: "MagicPlanPlan" + + @dataclass class MagicPlanPlan: id: str 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..f30417fe --- /dev/null +++ b/backend/magic_plan/tests/test_magic_plan_client.py @@ -0,0 +1,195 @@ +import json +import xml.etree.ElementTree as ET +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +import requests + +from backend.magic_plan.magic_plan_client import MagicPlanClient +from backend.magic_plan.models import MagicPlanDetail, MagicPlanPlan, MagicPlanSummary + +FIXTURES = Path(__file__).parent.parent +API_KEY = "test-api-key" +PLAN_ID = "b66bb427-33fe-4865-8d57-bad7d9d3f2e5" +PROJECT_ID = "test-project-123" +BASE_URL = "https://cloud.magicplan.app/api/v2" + + +def _load_xml() -> str: + content = (FIXTURES / "xml_example.xml").read_text().strip() + try: + ET.fromstring(content) + return content + except ET.ParseError: + return json.loads(content) # type: ignore[return-value] + + +def _summary_payload(plan_id: str = PLAN_ID, index: int = 0) -> dict[str, str]: + return { + "id": plan_id, + "project_id": PROJECT_ID, + "name": f"Plan {index}", + "address": f"Address {index}", + "creation_date": "2026-04-20", + "update_date": "2026-04-20", + "workgroup_id": "wg-123", + "team_id": "team-123", + "created_by": "user-123", + "thumbnail_url": "https://example.com/thumb.jpg", + "public_url": "https://example.com/plan", + "cloud_url": "https://example.com/cloud", + "3d_url": "https://example.com/3d", + } + + +def _get_plan_response(xml_str: str) -> dict: # type: ignore[type-arg] + return { + "message": "success", + "data": { + "plan": _summary_payload(), + "plan_detail": { + "magicplan_format_xml": xml_str, + }, + }, + } + + +def _get_plans_response(plan_ids: list[str]) -> dict: # type: ignore[type-arg] + return { + "message": "success", + "data": [_summary_payload(pid, i) for i, pid in enumerate(plan_ids)], + } + + +@pytest.fixture +def mock_session() -> MagicMock: + with patch("backend.magic_plan.magic_plan_client.requests.Session") as MockSession: + session: MagicMock = MagicMock() + MockSession.return_value = session + yield session + + +@pytest.fixture +def client(mock_session: MagicMock) -> MagicPlanClient: + return MagicPlanClient(api_key=API_KEY) + + +# --------------------------------------------------------------------------- +# Initialisation +# --------------------------------------------------------------------------- + +class TestInit: + + def test_sets_bearer_auth_header(self, mock_session: MagicMock) -> None: + MagicPlanClient(api_key=API_KEY) + mock_session.headers.update.assert_called_once_with( + {"Authorization": f"Bearer {API_KEY}"} + ) + + +# --------------------------------------------------------------------------- +# get_plan_xml +# --------------------------------------------------------------------------- + +class TestGetPlanXml: + + def test_calls_correct_url(self, client: MagicPlanClient, mock_session: MagicMock) -> None: + mock_session.get.return_value.json.return_value = _get_plan_response(_load_xml()) + client.get_plan_xml(PLAN_ID) + mock_session.get.assert_called_once_with(f"{BASE_URL}/plans/get/{PLAN_ID}") + + def test_calls_raise_for_status(self, client: MagicPlanClient, mock_session: MagicMock) -> None: + mock_session.get.return_value.json.return_value = _get_plan_response(_load_xml()) + client.get_plan_xml(PLAN_ID) + mock_session.get.return_value.raise_for_status.assert_called_once() + + def test_returns_magic_plan_detail(self, client: MagicPlanClient, mock_session: MagicMock) -> None: + mock_session.get.return_value.json.return_value = _get_plan_response(_load_xml()) + result = client.get_plan_xml(PLAN_ID) + assert isinstance(result, MagicPlanDetail) + + def test_detail_contains_summary(self, client: MagicPlanClient, mock_session: MagicMock) -> None: + mock_session.get.return_value.json.return_value = _get_plan_response(_load_xml()) + result = client.get_plan_xml(PLAN_ID) + assert isinstance(result.summary, MagicPlanSummary) + + def test_detail_summary_id(self, client: MagicPlanClient, mock_session: MagicMock) -> None: + mock_session.get.return_value.json.return_value = _get_plan_response(_load_xml()) + result = client.get_plan_xml(PLAN_ID) + assert result.summary.id == PLAN_ID + + def test_detail_summary_project_id(self, client: MagicPlanClient, mock_session: MagicMock) -> None: + mock_session.get.return_value.json.return_value = _get_plan_response(_load_xml()) + result = client.get_plan_xml(PLAN_ID) + assert result.summary.project_id == PROJECT_ID + + def test_detail_contains_parsed_plan(self, client: MagicPlanClient, mock_session: MagicMock) -> None: + mock_session.get.return_value.json.return_value = _get_plan_response(_load_xml()) + result = client.get_plan_xml(PLAN_ID) + assert isinstance(result.plan_xml, MagicPlanPlan) + + def test_plan_xml_id_matches_xml(self, client: MagicPlanClient, mock_session: MagicMock) -> None: + mock_session.get.return_value.json.return_value = _get_plan_response(_load_xml()) + result = client.get_plan_xml(PLAN_ID) + assert result.plan_xml.id == PLAN_ID + + def test_plan_xml_floor_count(self, client: MagicPlanClient, mock_session: MagicMock) -> None: + mock_session.get.return_value.json.return_value = _get_plan_response(_load_xml()) + result = client.get_plan_xml(PLAN_ID) + assert len(result.plan_xml.floors) == 2 + + def test_raises_on_http_error(self, client: MagicPlanClient, mock_session: MagicMock) -> None: + mock_session.get.return_value.raise_for_status.side_effect = requests.HTTPError("404") + with pytest.raises(requests.HTTPError): + client.get_plan_xml(PLAN_ID) + + +# --------------------------------------------------------------------------- +# get_plans +# --------------------------------------------------------------------------- + +class TestGetPlans: + + def test_calls_correct_url(self, client: MagicPlanClient, mock_session: MagicMock) -> None: + mock_session.get.return_value.json.return_value = _get_plans_response([]) + client.get_plans(PROJECT_ID) + mock_session.get.assert_called_once_with( + f"{BASE_URL}/plans", params={"project_id": PROJECT_ID} + ) + + def test_calls_raise_for_status(self, client: MagicPlanClient, mock_session: MagicMock) -> None: + mock_session.get.return_value.json.return_value = _get_plans_response([]) + client.get_plans(PROJECT_ID) + mock_session.get.return_value.raise_for_status.assert_called_once() + + def test_returns_list_of_summaries(self, client: MagicPlanClient, mock_session: MagicMock) -> None: + mock_session.get.return_value.json.return_value = _get_plans_response([PLAN_ID, "other-id"]) + result = client.get_plans(PROJECT_ID) + assert len(result) == 2 + assert all(isinstance(s, MagicPlanSummary) for s in result) + + def test_summary_id(self, client: MagicPlanClient, mock_session: MagicMock) -> None: + mock_session.get.return_value.json.return_value = _get_plans_response([PLAN_ID]) + result = client.get_plans(PROJECT_ID) + assert result[0].id == PLAN_ID + + def test_summary_project_id(self, client: MagicPlanClient, mock_session: MagicMock) -> None: + mock_session.get.return_value.json.return_value = _get_plans_response([PLAN_ID]) + result = client.get_plans(PROJECT_ID) + assert result[0].project_id == PROJECT_ID + + def test_summary_name(self, client: MagicPlanClient, mock_session: MagicMock) -> None: + mock_session.get.return_value.json.return_value = _get_plans_response([PLAN_ID]) + result = client.get_plans(PROJECT_ID) + assert result[0].name == "Plan 0" + + def test_returns_empty_list_when_no_plans(self, client: MagicPlanClient, mock_session: MagicMock) -> None: + mock_session.get.return_value.json.return_value = _get_plans_response([]) + result = client.get_plans(PROJECT_ID) + assert result == [] + + def test_raises_on_http_error(self, client: MagicPlanClient, mock_session: MagicMock) -> None: + mock_session.get.return_value.raise_for_status.side_effect = requests.HTTPError("401") + with pytest.raises(requests.HTTPError): + client.get_plans(PROJECT_ID)