test_tools.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575
  1. """
  2. Integration tests for MCP tools with mocked ephemeris client.
  3. """
  4. from __future__ import annotations
  5. import asyncio
  6. import json
  7. from unittest.mock import AsyncMock, patch
  8. import pytest
  9. from fastapi.testclient import TestClient
  10. from src.astro_mcp.server import create_app
  11. # ── Mock ephemeris get_sky_state response ───────────────────────────
  12. def make_mock_sky_state(
  13. bodies: list[dict] | None = None,
  14. lst: float = 6.0,
  15. ) -> dict:
  16. """Create a realistic mock get_sky_state response."""
  17. if bodies is None:
  18. bodies = [
  19. {"body": "sun", "ecliptic_lon": 25.0, "ecliptic_lat": 0.0,
  20. "distance_au": 1.0, "speed_lon": 1.0, "speed_lat": 0.0,
  21. "speed_dist": 0.0, "ra": 1.5, "dec": 10.0},
  22. {"body": "moon", "ecliptic_lon": 120.5, "ecliptic_lat": 2.0,
  23. "distance_au": 0.00257, "speed_lon": 13.0, "speed_lat": 0.5,
  24. "speed_dist": 0.0, "ra": 8.2, "dec": 15.0},
  25. {"body": "mercury", "ecliptic_lon": 30.0, "ecliptic_lat": 1.0,
  26. "distance_au": 0.5, "speed_lon": 1.5, "speed_lat": 0.0,
  27. "speed_dist": 0.0, "ra": 2.0, "dec": 12.0},
  28. {"body": "venus", "ecliptic_lon": 95.0, "ecliptic_lat": -1.0,
  29. "distance_au": 0.7, "speed_lon": 0.8, "speed_lat": 0.0,
  30. "speed_dist": 0.0, "ra": 6.5, "dec": 20.0},
  31. {"body": "mars", "ecliptic_lon": 200.0, "ecliptic_lat": 3.0,
  32. "distance_au": 1.5, "speed_lon": 0.6, "speed_lat": 0.0,
  33. "speed_dist": 0.0, "ra": 13.5, "dec": -5.0},
  34. {"body": "jupiter", "ecliptic_lon": 250.0, "ecliptic_lat": 1.0,
  35. "distance_au": 5.2, "speed_lon": 0.1, "speed_lat": 0.0,
  36. "speed_dist": 0.0, "ra": 16.8, "dec": -15.0},
  37. {"body": "saturn", "ecliptic_lon": 300.0, "ecliptic_lat": -2.0,
  38. "distance_au": 9.5, "speed_lon": 0.05, "speed_lat": 0.0,
  39. "speed_dist": 0.0, "ra": 20.2, "dec": -20.0},
  40. {"body": "uranus", "ecliptic_lon": 330.0, "ecliptic_lat": 0.5,
  41. "distance_au": 19.2, "speed_lon": 0.02, "speed_lat": 0.0,
  42. "speed_dist": 0.0, "ra": 22.5, "dec": -10.0},
  43. {"body": "neptune", "ecliptic_lon": 310.0, "ecliptic_lat": -1.0,
  44. "distance_au": 30.0, "speed_lon": 0.01, "speed_lat": 0.0,
  45. "speed_dist": 0.0, "ra": 21.0, "dec": -18.0},
  46. {"body": "pluto", "ecliptic_lon": 260.0, "ecliptic_lat": 2.0,
  47. "distance_au": 39.5, "speed_lon": -0.01, "speed_lat": 0.0,
  48. "speed_dist": 0.0, "ra": 17.5, "dec": -22.0},
  49. {"body": "chiron", "ecliptic_lon": 15.0, "ecliptic_lat": 0.0,
  50. "distance_au": 10.0, "speed_lon": 0.03, "speed_lat": 0.0,
  51. "speed_dist": 0.0, "ra": 1.0, "dec": 8.0},
  52. {"body": "true_node", "ecliptic_lon": 45.0, "ecliptic_lat": 0.0,
  53. "distance_au": 0.0, "speed_lon": -0.05, "speed_lat": 0.0,
  54. "speed_dist": 0.0, "ra": 3.0, "dec": 15.0},
  55. ]
  56. return {
  57. "input": {"datetime": "2026-06-02T12:00:00Z", "lat": 47.0, "lon": 8.0,
  58. "elevation": 0.0, "geocentric": True},
  59. "timestamp_utc": "2026-06-02T12:00:00Z",
  60. "julian_day": 2461172.0,
  61. "planetary_positions": {
  62. "input": {"datetime": "2026-06-02T12:00:00Z", "lat": 47.0, "lon": 8.0,
  63. "elevation": 0.0, "geocentric": True},
  64. "timestamp_utc": "2026-06-02T12:00:00Z",
  65. "julian_day": 2461172.0,
  66. "bodies": bodies,
  67. },
  68. "solar_events": {"date": "2026-06-02", "events_jd": {}},
  69. "lunar_state": {"phase_name": "Waxing Gibbous", "illumination_fraction": 0.75},
  70. "sidereal_time": {
  71. "greenwich_sidereal_time": lst,
  72. "local_sidereal_time": lst,
  73. "obliquity_of_ecliptic": 23.4367,
  74. },
  75. "houses": {
  76. "system": "PLACIDUS",
  77. "cusps": [
  78. {"house": 1, "absolute_lon": 98.0, "sign": "Cancer", "abbreviation": "Cn", "degree": 8.0},
  79. {"house": 2, "absolute_lon": 128.0, "sign": "Leo", "abbreviation": "Le", "degree": 8.0},
  80. {"house": 3, "absolute_lon": 158.0, "sign": "Virgo", "abbreviation": "Vi", "degree": 8.0},
  81. {"house": 4, "absolute_lon": 188.0, "sign": "Libra", "abbreviation": "Li", "degree": 8.0},
  82. {"house": 5, "absolute_lon": 218.0, "sign": "Scorpius", "abbreviation": "Sc", "degree": 8.0},
  83. {"house": 6, "absolute_lon": 248.0, "sign": "Sagittarius", "abbreviation": "Sg", "degree": 8.0},
  84. {"house": 7, "absolute_lon": 278.0, "sign": "Capricornus", "abbreviation": "Cp", "degree": 8.0},
  85. {"house": 8, "absolute_lon": 308.0, "sign": "Aquarius", "abbreviation": "Aq", "degree": 8.0},
  86. {"house": 9, "absolute_lon": 338.0, "sign": "Pisces", "abbreviation": "Pi", "degree": 8.0},
  87. {"house": 10, "absolute_lon": 8.0, "sign": "Aries", "abbreviation": "Ar", "degree": 8.0},
  88. {"house": 11, "absolute_lon": 38.0, "sign": "Taurus", "abbreviation": "Ta", "degree": 8.0},
  89. {"house": 12, "absolute_lon": 68.0, "sign": "Gemini", "abbreviation": "Ge", "degree": 8.0},
  90. ],
  91. "ascendant": 98.0,
  92. "midheaven": 8.0,
  93. "descendant": 278.0,
  94. "imum_coeli": 188.0,
  95. },
  96. }
  97. # ── Fixtures ────────────────────────────────────────────────────────
  98. @pytest.fixture
  99. def mock_sky_state():
  100. return make_mock_sky_state()
  101. @pytest.fixture
  102. def app_with_mock(mock_sky_state):
  103. """Create app with mocked ephemeris client."""
  104. with patch("src.astro_mcp.tools.call_sky_state", new_callable=AsyncMock) as mock:
  105. mock.return_value = mock_sky_state
  106. app = create_app()
  107. yield app, mock
  108. # ── Tests: get_planetary_positions ──────────────────────────────────
  109. class TestGetPlanetaryPositions:
  110. def test_returns_enhanced_bodies(self, app_with_mock):
  111. app, mock = app_with_mock
  112. client = TestClient(app)
  113. res = client.get("/health")
  114. assert res.status_code == 200
  115. def test_tool_call_via_mock(self, mock_sky_state):
  116. """Test the tool function directly with mocked ephemeris client."""
  117. from src.astro_mcp import tools
  118. with patch("src.astro_mcp.tools.call_sky_state", new_callable=AsyncMock) as mock:
  119. mock.return_value = mock_sky_state
  120. result = asyncio.run(
  121. tools.get_planetary_positions(datetime="2026-06-02T12:00:00Z", lat=47.0, lon=8.0)
  122. )
  123. assert "bodies" in result
  124. assert len(result["bodies"]) == 12
  125. # Check sun is in Aries (25°)
  126. sun = [b for b in result["bodies"] if b["body"] == "sun"][0]
  127. assert sun["sign"] == "Aries"
  128. assert abs(sun["degree_within_sign"] - 25.0) < 0.01
  129. assert sun["retrograde"] is False
  130. # Check Pluto is retrograde (speed < 0)
  131. pluto = [b for b in result["bodies"] if b["body"] == "pluto"][0]
  132. assert pluto["retrograde"] is True
  133. def test_bodies_filter(self, mock_sky_state):
  134. from src.astro_mcp import tools
  135. with patch("src.astro_mcp.tools.call_sky_state", new_callable=AsyncMock) as mock:
  136. mock.return_value = mock_sky_state
  137. result = asyncio.run(
  138. tools.get_planetary_positions(
  139. datetime="2026-06-02T12:00:00Z",
  140. lat=47.0, lon=8.0,
  141. bodies=["sun", "moon"],
  142. )
  143. )
  144. assert len(result["bodies"]) == 2
  145. names = {b["body"] for b in result["bodies"]}
  146. assert names == {"sun", "moon"}
  147. def test_error_handling(self):
  148. from src.astro_mcp import tools
  149. with patch("src.astro_mcp.tools.call_sky_state", new_callable=AsyncMock) as mock:
  150. mock.return_value = {"error": "connection refused"}
  151. result = asyncio.run(
  152. tools.get_planetary_positions(datetime="2026-06-02T12:00:00Z")
  153. )
  154. assert "error" in result
  155. # ── Tests: calculate_natal_chart ────────────────────────────────────
  156. class TestCalculateNatalChart:
  157. def test_full_natal_chart(self, mock_sky_state):
  158. from src.astro_mcp import tools
  159. with patch("src.astro_mcp.tools.call_sky_state", new_callable=AsyncMock) as mock:
  160. mock.return_value = mock_sky_state
  161. result = asyncio.run(
  162. tools.calculate_natal_chart(
  163. birth_datetime="2026-06-02T12:00:00Z",
  164. latitude=47.0,
  165. longitude=8.0,
  166. )
  167. )
  168. assert result["chart_type"] == "natal"
  169. assert "planets" in result
  170. assert "houses" in result
  171. assert "aspects" in result
  172. assert "angles" in result
  173. # 12 planets
  174. assert len(result["planets"]) == 12
  175. # 12 houses
  176. assert len(result["houses"]) == 12
  177. # Each planet has required fields
  178. for p in result["planets"]:
  179. assert "body" in p
  180. assert "sign" in p
  181. assert "degree_within_sign" in p
  182. assert "house" in p
  183. assert "retrograde" in p
  184. assert 1 <= p["house"] <= 12
  185. # Angles
  186. for key in ("ascendant", "midheaven", "descendant", "imum_coeli"):
  187. assert key in result["angles"]
  188. assert "sign" in result["angles"][key]
  189. # Aspects have required fields
  190. for asp in result["aspects"]:
  191. assert "body1" in asp
  192. assert "body2" in asp
  193. assert "aspect" in asp
  194. assert "orb" in asp
  195. def test_custom_house_system(self, mock_sky_state):
  196. from src.astro_mcp import tools
  197. with patch("src.astro_mcp.tools.call_sky_state", new_callable=AsyncMock) as mock:
  198. mock.return_value = mock_sky_state
  199. result = asyncio.run(
  200. tools.calculate_natal_chart(
  201. birth_datetime="2026-06-02T12:00:00Z",
  202. latitude=47.0,
  203. longitude=8.0,
  204. house_system="equal",
  205. )
  206. )
  207. assert result["input"]["house_system"] == "equal"
  208. assert len(result["houses"]) == 12
  209. # ── Tests: calculate_transit_chart ──────────────────────────────────
  210. class TestCalculateTransitChart:
  211. def test_transit_chart(self, mock_sky_state):
  212. from src.astro_mcp import tools
  213. with patch("src.astro_mcp.tools.call_sky_state", new_callable=AsyncMock) as mock:
  214. mock.return_value = mock_sky_state
  215. result = asyncio.run(
  216. tools.calculate_transit_chart(
  217. birth_datetime="2000-01-01T12:00:00Z",
  218. transit_datetime="2026-06-02T12:00:00Z",
  219. latitude=47.0,
  220. longitude=8.0,
  221. )
  222. )
  223. assert result["chart_type"] == "transit"
  224. assert "natal_planets" in result
  225. assert "transiting_planets" in result
  226. assert "aspects" in result
  227. assert "houses" in result
  228. # Transit planets should have natal_house
  229. for tp in result["transiting_planets"]:
  230. assert "natal_house" in tp
  231. assert 1 <= tp["natal_house"] <= 12
  232. # ── Tests: calculate_synastry_chart ─────────────────────────────────
  233. class TestCalculateSynastryChart:
  234. def test_synastry_chart(self, mock_sky_state):
  235. from src.astro_mcp import tools
  236. with patch("src.astro_mcp.tools.call_sky_state", new_callable=AsyncMock) as mock:
  237. mock.return_value = mock_sky_state
  238. result = asyncio.run(
  239. tools.calculate_synastry_chart(
  240. person1_datetime="2000-01-01T12:00:00Z",
  241. person1_latitude=47.0,
  242. person1_longitude=8.0,
  243. person2_datetime="1995-06-15T08:00:00Z",
  244. person2_latitude=52.0,
  245. person2_longitude=13.0,
  246. )
  247. )
  248. assert result["chart_type"] == "synastry"
  249. assert "chart1_natal" in result
  250. assert "chart2_natal" in result
  251. assert "interaspects" in result
  252. assert "house_overlays" in result
  253. assert "composite_chart" in result
  254. assert "davison_chart" in result
  255. # House overlays
  256. assert "person2_in_person1_houses" in result["house_overlays"]
  257. assert "person1_in_person2_houses" in result["house_overlays"]
  258. # Composite chart has planets
  259. assert "planets" in result["composite_chart"]
  260. # ── Tests: calculate_composite_chart ─────────────────────────────────
  261. class TestCalculateCompositeChart:
  262. def test_composite_chart(self):
  263. from src.astro_mcp import tools
  264. with patch("src.astro_mcp.tools.call_sky_state", new_callable=AsyncMock) as mock:
  265. mock.return_value = make_mock_sky_state()
  266. import asyncio
  267. result = asyncio.run(
  268. tools.calculate_composite_chart(
  269. person1_datetime="2000-01-01T12:00:00Z",
  270. person1_latitude=47.0,
  271. person1_longitude=8.0,
  272. person2_datetime="1995-06-15T08:00:00Z",
  273. person2_latitude=52.0,
  274. person2_longitude=13.0,
  275. )
  276. )
  277. assert result["chart_type"] == "composite"
  278. assert "planets" in result
  279. assert "houses" in result
  280. assert "aspects" in result
  281. assert "angles" in result
  282. assert "composite_location" in result
  283. assert len(result["planets"]) > 0
  284. assert len(result["houses"]) == 12
  285. # ── Tests: list_house_systems ───────────────────────────────────────
  286. class TestListHouseSystems:
  287. def test_returns_systems(self):
  288. from src.astro_mcp import tools
  289. result = tools.list_house_systems()
  290. assert "systems" in result
  291. ids = [s["id"] for s in result["systems"]]
  292. assert "placidus" in ids
  293. assert "equal" in ids
  294. assert "whole_sign" in ids
  295. # ── Tests: person_manage (stub) ─────────────────────────────────────
  296. class TestPersonManage:
  297. def test_add_and_get(self, tmp_path, monkeypatch):
  298. """Test add + get round-trip with real storage."""
  299. from src.astro_mcp import tools, storage
  300. db_path = tmp_path / "test.sqlite3"
  301. monkeypatch.setattr(storage, "DB_PATH", db_path)
  302. storage._initialized = False
  303. import asyncio
  304. # Add
  305. add_result = asyncio.run(tools.person_manage(
  306. action="add",
  307. name="Lucky",
  308. birth_datetime="1990-05-15T10:30:00Z",
  309. latitude=47.3,
  310. longitude=8.5,
  311. nickname="lucky",
  312. ))
  313. assert "person" in add_result
  314. assert add_result["person"]["name"] == "Lucky"
  315. person_id = add_result["person"]["id"]
  316. # Get by ID
  317. get_result = asyncio.run(tools.person_manage(
  318. action="get", person_id=person_id,
  319. ))
  320. assert get_result["person"]["name"] == "Lucky"
  321. # Get by nickname
  322. get_result2 = asyncio.run(tools.person_manage(
  323. action="get", nickname="lucky",
  324. ))
  325. assert get_result2["person"]["id"] == person_id
  326. def test_list_persons(self, tmp_path, monkeypatch):
  327. from src.astro_mcp import tools, storage
  328. db_path = tmp_path / "test.sqlite3"
  329. monkeypatch.setattr(storage, "DB_PATH", db_path)
  330. storage._initialized = False
  331. import asyncio
  332. for i in range(3):
  333. asyncio.run(tools.person_manage(
  334. action="add", name=f"P{i}",
  335. birth_datetime="2000-01-01T12:00:00Z",
  336. latitude=0.0, longitude=0.0,
  337. ))
  338. result = asyncio.run(tools.person_manage(action="list"))
  339. assert result["count"] == 3
  340. def test_update_person(self, tmp_path, monkeypatch):
  341. from src.astro_mcp import tools, storage
  342. db_path = tmp_path / "test.sqlite3"
  343. monkeypatch.setattr(storage, "DB_PATH", db_path)
  344. storage._initialized = False
  345. import asyncio
  346. add_result = asyncio.run(tools.person_manage(
  347. action="add", name="Original",
  348. birth_datetime="2000-01-01T12:00:00Z",
  349. latitude=0.0, longitude=0.0,
  350. ))
  351. pid = add_result["person"]["id"]
  352. update_result = asyncio.run(tools.person_manage(
  353. action="update", person_id=pid, name="Updated",
  354. ))
  355. assert update_result["person"]["name"] == "Updated"
  356. def test_delete_person(self, tmp_path, monkeypatch):
  357. from src.astro_mcp import tools, storage
  358. db_path = tmp_path / "test.sqlite3"
  359. monkeypatch.setattr(storage, "DB_PATH", db_path)
  360. storage._initialized = False
  361. import asyncio
  362. add_result = asyncio.run(tools.person_manage(
  363. action="add", name="ToDelete",
  364. birth_datetime="2000-01-01T12:00:00Z",
  365. latitude=0.0, longitude=0.0,
  366. ))
  367. pid = add_result["person"]["id"]
  368. del_result = asyncio.run(tools.person_manage(
  369. action="delete", person_id=pid,
  370. ))
  371. assert del_result["deleted"] is True
  372. get_result = asyncio.run(tools.person_manage(
  373. action="get", person_id=pid,
  374. ))
  375. assert "error" in get_result
  376. def test_missing_required_fields(self, tmp_path, monkeypatch):
  377. from src.astro_mcp import tools, storage
  378. db_path = tmp_path / "test.sqlite3"
  379. monkeypatch.setattr(storage, "DB_PATH", db_path)
  380. storage._initialized = False
  381. import asyncio
  382. result = asyncio.run(tools.person_manage(
  383. action="add", name="Test",
  384. ))
  385. assert "error" in result
  386. def test_unknown_action(self, tmp_path, monkeypatch):
  387. from src.astro_mcp import tools, storage
  388. db_path = tmp_path / "test.sqlite3"
  389. monkeypatch.setattr(storage, "DB_PATH", db_path)
  390. storage._initialized = False
  391. import asyncio
  392. result = asyncio.run(tools.person_manage(action="bogus"))
  393. assert "error" in result
  394. # ── Tests: get_transit_preview (stub) ───────────────────────────────
  395. class TestTransitPreview:
  396. def test_invalid_date_format(self):
  397. from src.astro_mcp import tools
  398. result = asyncio.run(
  399. tools.get_transit_preview(
  400. birth_datetime="1980-01-01T12:00:00Z",
  401. latitude=47.0, longitude=8.0,
  402. start_date="not-a-date",
  403. end_date="2026-07-01",
  404. )
  405. )
  406. assert "error" in result
  407. def test_end_before_start(self):
  408. from src.astro_mcp import tools
  409. result = asyncio.run(
  410. tools.get_transit_preview(
  411. birth_datetime="1980-01-01T12:00:00Z",
  412. latitude=47.0, longitude=8.0,
  413. start_date="2026-07-01",
  414. end_date="2026-06-01",
  415. )
  416. )
  417. assert "error" in result
  418. def test_range_too_large(self):
  419. from src.astro_mcp import tools
  420. result = asyncio.run(
  421. tools.get_transit_preview(
  422. birth_datetime="1980-01-01T12:00:00Z",
  423. latitude=47.0, longitude=8.0,
  424. start_date="2026-01-01",
  425. end_date="2027-06-01",
  426. )
  427. )
  428. assert "error" in result
  429. def test_min_significance(self, mock_sky_state):
  430. from src.astro_mcp import tools
  431. with patch("src.astro_mcp.tools.call_sky_state", new_callable=AsyncMock) as mock:
  432. mock.return_value = mock_sky_state
  433. result = asyncio.run(
  434. tools.get_transit_preview(
  435. birth_datetime="1980-01-01T12:00:00Z",
  436. latitude=47.0, longitude=8.0,
  437. start_date="2026-06-01",
  438. end_date="2026-06-03",
  439. min_significance=5.0,
  440. )
  441. )
  442. assert "days" in result
  443. # ── Tests: _byId tools ───────────────────────────────────────────────
  444. class TestByIdTools:
  445. def test_calculate_natal_chart_by_id(self, tmp_path, monkeypatch):
  446. from src.astro_mcp import tools, storage
  447. db_path = tmp_path / "test.sqlite3"
  448. monkeypatch.setattr(storage, "DB_PATH", db_path)
  449. storage._initialized = False
  450. import asyncio
  451. pid = asyncio.run(tools.person_manage(
  452. action="add", name="Lucky",
  453. birth_datetime="1980-06-15T10:30:00",
  454. latitude=47.0, longitude=8.0,
  455. nickname="lucky", birthplace="Zurich",
  456. ))["person"]["id"]
  457. result = asyncio.run(tools.calculate_natal_chart_by_id(person_id=pid))
  458. assert "error" not in result
  459. assert result["chart_type"] == "natal"
  460. assert len(result["planets"]) == 13
  461. def test_calculate_transit_chart_by_id(self, tmp_path, monkeypatch):
  462. from src.astro_mcp import tools, storage
  463. db_path = tmp_path / "test2.sqlite3"
  464. monkeypatch.setattr(storage, "DB_PATH", db_path)
  465. storage._initialized = False
  466. import asyncio
  467. pid = asyncio.run(tools.person_manage(
  468. action="add", name="Transit",
  469. birth_datetime="1990-03-20T08:00:00",
  470. latitude=52.0, longitude=13.0,
  471. nickname="transit_test", birthplace="Berlin",
  472. ))["person"]["id"]
  473. result = asyncio.run(tools.calculate_transit_chart_by_id(
  474. person_id=pid, transit_datetime="2026-06-02T12:00:00",
  475. ))
  476. assert "error" not in result
  477. assert result["chart_type"] == "transit"
  478. def test_by_id_not_found(self):
  479. from src.astro_mcp import tools
  480. import asyncio
  481. result = asyncio.run(tools.calculate_natal_chart_by_id(person_id="nonexistent"))
  482. assert "error" in result