test_tools.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510
  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. }
  76. # ── Fixtures ────────────────────────────────────────────────────────
  77. @pytest.fixture
  78. def mock_sky_state():
  79. return make_mock_sky_state()
  80. @pytest.fixture
  81. def app_with_mock(mock_sky_state):
  82. """Create app with mocked ephemeris client."""
  83. with patch("src.astro_mcp.tools.call_sky_state", new_callable=AsyncMock) as mock:
  84. mock.return_value = mock_sky_state
  85. app = create_app()
  86. yield app, mock
  87. # ── Tests: get_planetary_positions ──────────────────────────────────
  88. class TestGetPlanetaryPositions:
  89. def test_returns_enhanced_bodies(self, app_with_mock):
  90. app, mock = app_with_mock
  91. client = TestClient(app)
  92. res = client.get("/health")
  93. assert res.status_code == 200
  94. def test_tool_call_via_mock(self, mock_sky_state):
  95. """Test the tool function directly with mocked ephemeris client."""
  96. from src.astro_mcp import tools
  97. with patch("src.astro_mcp.tools.call_sky_state", new_callable=AsyncMock) as mock:
  98. mock.return_value = mock_sky_state
  99. result = asyncio.run(
  100. tools.get_planetary_positions(datetime="2026-06-02T12:00:00Z", lat=47.0, lon=8.0)
  101. )
  102. assert "bodies" in result
  103. assert len(result["bodies"]) == 12
  104. # Check sun is in Aries (25°)
  105. sun = [b for b in result["bodies"] if b["body"] == "sun"][0]
  106. assert sun["sign"] == "Aries"
  107. assert abs(sun["degree_within_sign"] - 25.0) < 0.01
  108. assert sun["retrograde"] is False
  109. # Check Pluto is retrograde (speed < 0)
  110. pluto = [b for b in result["bodies"] if b["body"] == "pluto"][0]
  111. assert pluto["retrograde"] is True
  112. def test_bodies_filter(self, mock_sky_state):
  113. from src.astro_mcp import tools
  114. with patch("src.astro_mcp.tools.call_sky_state", new_callable=AsyncMock) as mock:
  115. mock.return_value = mock_sky_state
  116. result = asyncio.run(
  117. tools.get_planetary_positions(
  118. datetime="2026-06-02T12:00:00Z",
  119. lat=47.0, lon=8.0,
  120. bodies=["sun", "moon"],
  121. )
  122. )
  123. assert len(result["bodies"]) == 2
  124. names = {b["body"] for b in result["bodies"]}
  125. assert names == {"sun", "moon"}
  126. def test_error_handling(self):
  127. from src.astro_mcp import tools
  128. with patch("src.astro_mcp.tools.call_sky_state", new_callable=AsyncMock) as mock:
  129. mock.return_value = {"error": "connection refused"}
  130. result = asyncio.run(
  131. tools.get_planetary_positions(datetime="2026-06-02T12:00:00Z")
  132. )
  133. assert "error" in result
  134. # ── Tests: calculate_natal_chart ────────────────────────────────────
  135. class TestCalculateNatalChart:
  136. def test_full_natal_chart(self, mock_sky_state):
  137. from src.astro_mcp import tools
  138. with patch("src.astro_mcp.tools.call_sky_state", new_callable=AsyncMock) as mock:
  139. mock.return_value = mock_sky_state
  140. result = asyncio.run(
  141. tools.calculate_natal_chart(
  142. birth_datetime="2026-06-02T12:00:00Z",
  143. latitude=47.0,
  144. longitude=8.0,
  145. )
  146. )
  147. assert result["chart_type"] == "natal"
  148. assert "planets" in result
  149. assert "houses" in result
  150. assert "aspects" in result
  151. assert "angles" in result
  152. # 12 planets
  153. assert len(result["planets"]) == 12
  154. # 12 houses
  155. assert len(result["houses"]) == 12
  156. # Each planet has required fields
  157. for p in result["planets"]:
  158. assert "body" in p
  159. assert "sign" in p
  160. assert "degree_within_sign" in p
  161. assert "house" in p
  162. assert "retrograde" in p
  163. assert 1 <= p["house"] <= 12
  164. # Angles
  165. for key in ("ascendant", "midheaven", "descendant", "imum_coeli"):
  166. assert key in result["angles"]
  167. assert "sign" in result["angles"][key]
  168. # Aspects have required fields
  169. for asp in result["aspects"]:
  170. assert "body1" in asp
  171. assert "body2" in asp
  172. assert "aspect" in asp
  173. assert "orb" in asp
  174. def test_custom_house_system(self, mock_sky_state):
  175. from src.astro_mcp import tools
  176. with patch("src.astro_mcp.tools.call_sky_state", new_callable=AsyncMock) as mock:
  177. mock.return_value = mock_sky_state
  178. result = asyncio.run(
  179. tools.calculate_natal_chart(
  180. birth_datetime="2026-06-02T12:00:00Z",
  181. latitude=47.0,
  182. longitude=8.0,
  183. house_system="equal",
  184. )
  185. )
  186. assert result["input"]["house_system"] == "equal"
  187. assert len(result["houses"]) == 12
  188. # ── Tests: calculate_transit_chart ──────────────────────────────────
  189. class TestCalculateTransitChart:
  190. def test_transit_chart(self, mock_sky_state):
  191. from src.astro_mcp import tools
  192. with patch("src.astro_mcp.tools.call_sky_state", new_callable=AsyncMock) as mock:
  193. mock.return_value = mock_sky_state
  194. result = asyncio.run(
  195. tools.calculate_transit_chart(
  196. birth_datetime="2000-01-01T12:00:00Z",
  197. transit_datetime="2026-06-02T12:00:00Z",
  198. latitude=47.0,
  199. longitude=8.0,
  200. )
  201. )
  202. assert result["chart_type"] == "transit"
  203. assert "natal_planets" in result
  204. assert "transiting_planets" in result
  205. assert "aspects" in result
  206. assert "houses" in result
  207. # Transit planets should have natal_house
  208. for tp in result["transiting_planets"]:
  209. assert "natal_house" in tp
  210. assert 1 <= tp["natal_house"] <= 12
  211. # ── Tests: calculate_synastry_chart ─────────────────────────────────
  212. class TestCalculateSynastryChart:
  213. def test_synastry_chart(self, mock_sky_state):
  214. from src.astro_mcp import tools
  215. with patch("src.astro_mcp.tools.call_sky_state", new_callable=AsyncMock) as mock:
  216. mock.return_value = mock_sky_state
  217. result = asyncio.run(
  218. tools.calculate_synastry_chart(
  219. person1_datetime="2000-01-01T12:00:00Z",
  220. person1_latitude=47.0,
  221. person1_longitude=8.0,
  222. person2_datetime="1995-06-15T08:00:00Z",
  223. person2_latitude=52.0,
  224. person2_longitude=13.0,
  225. )
  226. )
  227. assert result["chart_type"] == "synastry"
  228. assert "chart1_natal" in result
  229. assert "chart2_natal" in result
  230. assert "interaspects" in result
  231. assert "house_overlays" in result
  232. assert "composite_chart" in result
  233. assert "davison_chart" in result
  234. # House overlays
  235. assert "person2_in_person1_houses" in result["house_overlays"]
  236. assert "person1_in_person2_houses" in result["house_overlays"]
  237. # Composite chart has planets
  238. assert "planets" in result["composite_chart"]
  239. # ── Tests: calculate_composite_chart ─────────────────────────────────
  240. class TestCalculateCompositeChart:
  241. def test_composite_chart(self):
  242. from src.astro_mcp import tools
  243. with patch("src.astro_mcp.tools.call_sky_state", new_callable=AsyncMock) as mock:
  244. mock.return_value = make_mock_sky_state()
  245. import asyncio
  246. result = asyncio.run(
  247. tools.calculate_composite_chart(
  248. person1_datetime="2000-01-01T12:00:00Z",
  249. person1_latitude=47.0,
  250. person1_longitude=8.0,
  251. person2_datetime="1995-06-15T08:00:00Z",
  252. person2_latitude=52.0,
  253. person2_longitude=13.0,
  254. )
  255. )
  256. assert result["chart_type"] == "composite"
  257. assert "planets" in result
  258. assert "houses" in result
  259. assert "aspects" in result
  260. assert "angles" in result
  261. assert "composite_location" in result
  262. assert len(result["planets"]) > 0
  263. assert len(result["houses"]) == 12
  264. # ── Tests: list_house_systems ───────────────────────────────────────
  265. class TestListHouseSystems:
  266. def test_returns_systems(self):
  267. from src.astro_mcp import tools
  268. result = tools.list_house_systems()
  269. assert "systems" in result
  270. ids = [s["id"] for s in result["systems"]]
  271. assert "placidus" in ids
  272. assert "equal" in ids
  273. assert "whole_sign" in ids
  274. # ── Tests: person_manage (stub) ─────────────────────────────────────
  275. class TestPersonManage:
  276. def test_add_and_get(self, tmp_path, monkeypatch):
  277. """Test add + get round-trip with real storage."""
  278. from src.astro_mcp import tools, storage
  279. db_path = tmp_path / "test.sqlite3"
  280. monkeypatch.setattr(storage, "DB_PATH", db_path)
  281. storage._initialized = False
  282. import asyncio
  283. # Add
  284. add_result = asyncio.run(tools.person_manage(
  285. action="add",
  286. name="Lucky",
  287. birth_datetime="1990-05-15T10:30:00Z",
  288. latitude=47.3,
  289. longitude=8.5,
  290. nickname="lucky",
  291. ))
  292. assert "person" in add_result
  293. assert add_result["person"]["name"] == "Lucky"
  294. person_id = add_result["person"]["id"]
  295. # Get by ID
  296. get_result = asyncio.run(tools.person_manage(
  297. action="get", person_id=person_id,
  298. ))
  299. assert get_result["person"]["name"] == "Lucky"
  300. # Get by nickname
  301. get_result2 = asyncio.run(tools.person_manage(
  302. action="get", nickname="lucky",
  303. ))
  304. assert get_result2["person"]["id"] == person_id
  305. def test_list_persons(self, tmp_path, monkeypatch):
  306. from src.astro_mcp import tools, storage
  307. db_path = tmp_path / "test.sqlite3"
  308. monkeypatch.setattr(storage, "DB_PATH", db_path)
  309. storage._initialized = False
  310. import asyncio
  311. for i in range(3):
  312. asyncio.run(tools.person_manage(
  313. action="add", name=f"P{i}",
  314. birth_datetime="2000-01-01T12:00:00Z",
  315. latitude=0.0, longitude=0.0,
  316. ))
  317. result = asyncio.run(tools.person_manage(action="list"))
  318. assert result["count"] == 3
  319. def test_update_person(self, tmp_path, monkeypatch):
  320. from src.astro_mcp import tools, storage
  321. db_path = tmp_path / "test.sqlite3"
  322. monkeypatch.setattr(storage, "DB_PATH", db_path)
  323. storage._initialized = False
  324. import asyncio
  325. add_result = asyncio.run(tools.person_manage(
  326. action="add", name="Original",
  327. birth_datetime="2000-01-01T12:00:00Z",
  328. latitude=0.0, longitude=0.0,
  329. ))
  330. pid = add_result["person"]["id"]
  331. update_result = asyncio.run(tools.person_manage(
  332. action="update", person_id=pid, name="Updated",
  333. ))
  334. assert update_result["person"]["name"] == "Updated"
  335. def test_delete_person(self, tmp_path, monkeypatch):
  336. from src.astro_mcp import tools, storage
  337. db_path = tmp_path / "test.sqlite3"
  338. monkeypatch.setattr(storage, "DB_PATH", db_path)
  339. storage._initialized = False
  340. import asyncio
  341. add_result = asyncio.run(tools.person_manage(
  342. action="add", name="ToDelete",
  343. birth_datetime="2000-01-01T12:00:00Z",
  344. latitude=0.0, longitude=0.0,
  345. ))
  346. pid = add_result["person"]["id"]
  347. del_result = asyncio.run(tools.person_manage(
  348. action="delete", person_id=pid,
  349. ))
  350. assert del_result["deleted"] is True
  351. get_result = asyncio.run(tools.person_manage(
  352. action="get", person_id=pid,
  353. ))
  354. assert "error" in get_result
  355. def test_missing_required_fields(self, tmp_path, monkeypatch):
  356. from src.astro_mcp import tools, storage
  357. db_path = tmp_path / "test.sqlite3"
  358. monkeypatch.setattr(storage, "DB_PATH", db_path)
  359. storage._initialized = False
  360. import asyncio
  361. result = asyncio.run(tools.person_manage(
  362. action="add", name="Test",
  363. ))
  364. assert "error" in result
  365. def test_unknown_action(self, tmp_path, monkeypatch):
  366. from src.astro_mcp import tools, storage
  367. db_path = tmp_path / "test.sqlite3"
  368. monkeypatch.setattr(storage, "DB_PATH", db_path)
  369. storage._initialized = False
  370. import asyncio
  371. result = asyncio.run(tools.person_manage(action="bogus"))
  372. assert "error" in result
  373. # ── Tests: get_transit_preview (stub) ───────────────────────────────
  374. class TestTransitPreview:
  375. def test_person_not_found(self):
  376. from src.astro_mcp import tools
  377. result = asyncio.run(
  378. tools.get_transit_preview(
  379. person_id="nonexistent",
  380. start_date="2026-06-01",
  381. end_date="2026-07-01",
  382. )
  383. )
  384. assert "error" in result
  385. def test_invalid_date_format(self):
  386. from src.astro_mcp import tools
  387. result = asyncio.run(
  388. tools.get_transit_preview(
  389. person_id="test",
  390. start_date="not-a-date",
  391. end_date="2026-07-01",
  392. )
  393. )
  394. assert "error" in result
  395. def test_end_before_start(self):
  396. from src.astro_mcp import tools
  397. result = asyncio.run(
  398. tools.get_transit_preview(
  399. person_id="test",
  400. start_date="2026-07-01",
  401. end_date="2026-06-01",
  402. )
  403. )
  404. assert "error" in result
  405. def test_range_too_large(self):
  406. from src.astro_mcp import tools
  407. result = asyncio.run(
  408. tools.get_transit_preview(
  409. person_id="test",
  410. start_date="2026-01-01",
  411. end_date="2027-06-01",
  412. )
  413. )
  414. assert "error" in result
  415. def test_invalid_event_types(self):
  416. from src.astro_mcp import tools
  417. result = asyncio.run(
  418. tools.get_transit_preview(
  419. person_id="test",
  420. start_date="2026-06-01",
  421. end_date="2026-07-01",
  422. event_types=["invalid_type"],
  423. )
  424. )
  425. assert "error" in result