test_tools.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437
  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: list_house_systems ───────────────────────────────────────
  240. class TestListHouseSystems:
  241. def test_returns_systems(self):
  242. from src.astro_mcp import tools
  243. result = tools.list_house_systems()
  244. assert "systems" in result
  245. ids = [s["id"] for s in result["systems"]]
  246. assert "placidus" in ids
  247. assert "equal" in ids
  248. assert "whole_sign" in ids
  249. # ── Tests: person_manage (stub) ─────────────────────────────────────
  250. class TestPersonManage:
  251. def test_add_and_get(self, tmp_path, monkeypatch):
  252. """Test add + get round-trip with real storage."""
  253. from src.astro_mcp import tools, storage
  254. db_path = tmp_path / "test.sqlite3"
  255. monkeypatch.setattr(storage, "DB_PATH", db_path)
  256. storage._initialized = False
  257. import asyncio
  258. # Add
  259. add_result = asyncio.run(tools.person_manage(
  260. action="add",
  261. name="Lucky",
  262. birth_datetime="1990-05-15T10:30:00Z",
  263. latitude=47.3,
  264. longitude=8.5,
  265. nickname="lucky",
  266. ))
  267. assert "person" in add_result
  268. assert add_result["person"]["name"] == "Lucky"
  269. person_id = add_result["person"]["id"]
  270. # Get by ID
  271. get_result = asyncio.run(tools.person_manage(
  272. action="get", person_id=person_id,
  273. ))
  274. assert get_result["person"]["name"] == "Lucky"
  275. # Get by nickname
  276. get_result2 = asyncio.run(tools.person_manage(
  277. action="get", nickname="lucky",
  278. ))
  279. assert get_result2["person"]["id"] == person_id
  280. def test_list_persons(self, tmp_path, monkeypatch):
  281. from src.astro_mcp import tools, storage
  282. db_path = tmp_path / "test.sqlite3"
  283. monkeypatch.setattr(storage, "DB_PATH", db_path)
  284. storage._initialized = False
  285. import asyncio
  286. for i in range(3):
  287. asyncio.run(tools.person_manage(
  288. action="add", name=f"P{i}",
  289. birth_datetime="2000-01-01T12:00:00Z",
  290. latitude=0.0, longitude=0.0,
  291. ))
  292. result = asyncio.run(tools.person_manage(action="list"))
  293. assert result["count"] == 3
  294. def test_update_person(self, tmp_path, monkeypatch):
  295. from src.astro_mcp import tools, storage
  296. db_path = tmp_path / "test.sqlite3"
  297. monkeypatch.setattr(storage, "DB_PATH", db_path)
  298. storage._initialized = False
  299. import asyncio
  300. add_result = asyncio.run(tools.person_manage(
  301. action="add", name="Original",
  302. birth_datetime="2000-01-01T12:00:00Z",
  303. latitude=0.0, longitude=0.0,
  304. ))
  305. pid = add_result["person"]["id"]
  306. update_result = asyncio.run(tools.person_manage(
  307. action="update", person_id=pid, name="Updated",
  308. ))
  309. assert update_result["person"]["name"] == "Updated"
  310. def test_delete_person(self, tmp_path, monkeypatch):
  311. from src.astro_mcp import tools, storage
  312. db_path = tmp_path / "test.sqlite3"
  313. monkeypatch.setattr(storage, "DB_PATH", db_path)
  314. storage._initialized = False
  315. import asyncio
  316. add_result = asyncio.run(tools.person_manage(
  317. action="add", name="ToDelete",
  318. birth_datetime="2000-01-01T12:00:00Z",
  319. latitude=0.0, longitude=0.0,
  320. ))
  321. pid = add_result["person"]["id"]
  322. del_result = asyncio.run(tools.person_manage(
  323. action="delete", person_id=pid,
  324. ))
  325. assert del_result["deleted"] is True
  326. get_result = asyncio.run(tools.person_manage(
  327. action="get", person_id=pid,
  328. ))
  329. assert "error" in get_result
  330. def test_missing_required_fields(self, tmp_path, monkeypatch):
  331. from src.astro_mcp import tools, storage
  332. db_path = tmp_path / "test.sqlite3"
  333. monkeypatch.setattr(storage, "DB_PATH", db_path)
  334. storage._initialized = False
  335. import asyncio
  336. result = asyncio.run(tools.person_manage(
  337. action="add", name="Test",
  338. ))
  339. assert "error" in result
  340. def test_unknown_action(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. result = asyncio.run(tools.person_manage(action="bogus"))
  347. assert "error" in result
  348. # ── Tests: get_transit_preview (stub) ───────────────────────────────
  349. class TestTransitPreview:
  350. def test_stub_returns_note(self):
  351. from src.astro_mcp import tools
  352. result = asyncio.run(
  353. tools.get_transit_preview(
  354. person_id="test",
  355. start_date="2026-06-01",
  356. end_date="2026-07-01",
  357. )
  358. )
  359. assert "_note" in result