test_smart_plugs_api.py 38 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978
  1. """Integration tests for Smart Plugs API endpoints.
  2. Tests the full request/response cycle for /api/v1/smart-plugs/ endpoints.
  3. """
  4. import pytest
  5. from httpx import AsyncClient
  6. class TestSmartPlugsAPI:
  7. """Integration tests for /api/v1/smart-plugs/ endpoints."""
  8. # ========================================================================
  9. # List endpoints
  10. # ========================================================================
  11. @pytest.mark.asyncio
  12. @pytest.mark.integration
  13. async def test_list_smart_plugs_empty(self, async_client: AsyncClient):
  14. """Verify empty list is returned when no plugs exist."""
  15. response = await async_client.get("/api/v1/smart-plugs/")
  16. assert response.status_code == 200
  17. assert response.json() == []
  18. @pytest.mark.asyncio
  19. @pytest.mark.integration
  20. async def test_list_smart_plugs_with_data(self, async_client: AsyncClient, smart_plug_factory, db_session):
  21. """Verify list returns existing plugs."""
  22. await smart_plug_factory(name="Test Plug 1")
  23. response = await async_client.get("/api/v1/smart-plugs/")
  24. assert response.status_code == 200
  25. data = response.json()
  26. assert len(data) >= 1
  27. assert any(p["name"] == "Test Plug 1" for p in data)
  28. # ========================================================================
  29. # Create endpoints
  30. # ========================================================================
  31. @pytest.mark.asyncio
  32. @pytest.mark.integration
  33. async def test_create_smart_plug(self, async_client: AsyncClient):
  34. """Verify smart plug can be created."""
  35. data = {
  36. "name": "New Plug",
  37. "ip_address": "192.168.1.100",
  38. "enabled": True,
  39. "auto_on": True,
  40. "auto_off": False,
  41. }
  42. response = await async_client.post("/api/v1/smart-plugs/", json=data)
  43. assert response.status_code == 200
  44. result = response.json()
  45. assert result["name"] == "New Plug"
  46. assert result["ip_address"] == "192.168.1.100"
  47. assert result["auto_off"] is False
  48. @pytest.mark.asyncio
  49. @pytest.mark.integration
  50. async def test_create_smart_plug_with_printer(self, async_client: AsyncClient, printer_factory, db_session):
  51. """Verify smart plug can be linked to a printer."""
  52. printer = await printer_factory(name="Test Printer")
  53. data = {
  54. "name": "Printer Plug",
  55. "ip_address": "192.168.1.101",
  56. "printer_id": printer.id,
  57. }
  58. response = await async_client.post("/api/v1/smart-plugs/", json=data)
  59. assert response.status_code == 200
  60. result = response.json()
  61. assert result["printer_id"] == printer.id
  62. @pytest.mark.asyncio
  63. @pytest.mark.integration
  64. async def test_create_plug_with_invalid_printer_id(self, async_client: AsyncClient):
  65. """Verify creating plug with non-existent printer fails."""
  66. data = {
  67. "name": "Test Plug",
  68. "ip_address": "192.168.1.100",
  69. "printer_id": 9999,
  70. }
  71. response = await async_client.post("/api/v1/smart-plugs/", json=data)
  72. assert response.status_code == 400
  73. assert "Printer not found" in response.json()["detail"]
  74. # ========================================================================
  75. # Get single endpoint
  76. # ========================================================================
  77. @pytest.mark.asyncio
  78. @pytest.mark.integration
  79. async def test_get_smart_plug(self, async_client: AsyncClient, smart_plug_factory, db_session):
  80. """Verify single plug can be retrieved."""
  81. plug = await smart_plug_factory(name="Get Test Plug")
  82. response = await async_client.get(f"/api/v1/smart-plugs/{plug.id}")
  83. assert response.status_code == 200
  84. result = response.json()
  85. assert result["id"] == plug.id
  86. assert result["name"] == "Get Test Plug"
  87. @pytest.mark.asyncio
  88. @pytest.mark.integration
  89. async def test_get_smart_plug_not_found(self, async_client: AsyncClient):
  90. """Verify 404 for non-existent plug."""
  91. response = await async_client.get("/api/v1/smart-plugs/9999")
  92. assert response.status_code == 404
  93. # ========================================================================
  94. # Update endpoints (CRITICAL - toggle persistence)
  95. # ========================================================================
  96. @pytest.mark.asyncio
  97. @pytest.mark.integration
  98. async def test_update_auto_off_toggle(self, async_client: AsyncClient, smart_plug_factory, db_session):
  99. """CRITICAL: Verify auto_off toggle persists correctly.
  100. This tests the regression scenario where toggling auto_off
  101. wasn't being saved properly.
  102. """
  103. # Create plug with auto_off=True
  104. plug = await smart_plug_factory(auto_off=True)
  105. # Verify initial state
  106. response = await async_client.get(f"/api/v1/smart-plugs/{plug.id}")
  107. assert response.status_code == 200
  108. assert response.json()["auto_off"] is True
  109. # Toggle auto_off to False
  110. response = await async_client.patch(f"/api/v1/smart-plugs/{plug.id}", json={"auto_off": False})
  111. assert response.status_code == 200
  112. assert response.json()["auto_off"] is False
  113. # Verify change persisted by fetching again
  114. response = await async_client.get(f"/api/v1/smart-plugs/{plug.id}")
  115. assert response.json()["auto_off"] is False
  116. @pytest.mark.asyncio
  117. @pytest.mark.integration
  118. async def test_update_auto_on_toggle(self, async_client: AsyncClient, smart_plug_factory, db_session):
  119. """Verify auto_on toggle persists correctly."""
  120. plug = await smart_plug_factory(auto_on=True)
  121. response = await async_client.patch(f"/api/v1/smart-plugs/{plug.id}", json={"auto_on": False})
  122. assert response.status_code == 200
  123. assert response.json()["auto_on"] is False
  124. # Verify persistence
  125. response = await async_client.get(f"/api/v1/smart-plugs/{plug.id}")
  126. assert response.json()["auto_on"] is False
  127. @pytest.mark.asyncio
  128. @pytest.mark.integration
  129. async def test_update_enabled_toggle(self, async_client: AsyncClient, smart_plug_factory, db_session):
  130. """Verify enabled toggle persists correctly."""
  131. plug = await smart_plug_factory(enabled=True)
  132. response = await async_client.patch(f"/api/v1/smart-plugs/{plug.id}", json={"enabled": False})
  133. assert response.status_code == 200
  134. assert response.json()["enabled"] is False
  135. @pytest.mark.asyncio
  136. @pytest.mark.integration
  137. async def test_update_off_delay_mode(self, async_client: AsyncClient, smart_plug_factory, db_session):
  138. """Verify off_delay_mode can be changed."""
  139. plug = await smart_plug_factory(off_delay_mode="time")
  140. response = await async_client.patch(
  141. f"/api/v1/smart-plugs/{plug.id}", json={"off_delay_mode": "temperature", "off_temp_threshold": 50}
  142. )
  143. assert response.status_code == 200
  144. result = response.json()
  145. assert result["off_delay_mode"] == "temperature"
  146. assert result["off_temp_threshold"] == 50
  147. @pytest.mark.asyncio
  148. @pytest.mark.integration
  149. async def test_update_schedule_settings(self, async_client: AsyncClient, smart_plug_factory, db_session):
  150. """Verify schedule settings can be updated."""
  151. plug = await smart_plug_factory(schedule_enabled=False)
  152. response = await async_client.patch(
  153. f"/api/v1/smart-plugs/{plug.id}",
  154. json={
  155. "schedule_enabled": True,
  156. "schedule_on_time": "08:00",
  157. "schedule_off_time": "22:00",
  158. },
  159. )
  160. assert response.status_code == 200
  161. result = response.json()
  162. assert result["schedule_enabled"] is True
  163. assert result["schedule_on_time"] == "08:00"
  164. assert result["schedule_off_time"] == "22:00"
  165. @pytest.mark.asyncio
  166. @pytest.mark.integration
  167. async def test_update_multiple_fields(self, async_client: AsyncClient, smart_plug_factory, db_session):
  168. """Verify multiple fields can be updated at once."""
  169. plug = await smart_plug_factory(
  170. name="Old Name",
  171. auto_on=True,
  172. auto_off=True,
  173. )
  174. response = await async_client.patch(
  175. f"/api/v1/smart-plugs/{plug.id}",
  176. json={
  177. "name": "New Name",
  178. "auto_on": False,
  179. "auto_off": False,
  180. },
  181. )
  182. assert response.status_code == 200
  183. result = response.json()
  184. assert result["name"] == "New Name"
  185. assert result["auto_on"] is False
  186. assert result["auto_off"] is False
  187. # ========================================================================
  188. # Control endpoints
  189. # ========================================================================
  190. @pytest.mark.asyncio
  191. @pytest.mark.integration
  192. async def test_control_smart_plug_on(
  193. self, async_client: AsyncClient, smart_plug_factory, mock_tasmota_service, db_session
  194. ):
  195. """Verify smart plug can be turned on."""
  196. plug = await smart_plug_factory()
  197. response = await async_client.post(f"/api/v1/smart-plugs/{plug.id}/control", json={"action": "on"})
  198. assert response.status_code == 200
  199. result = response.json()
  200. assert result["success"] is True
  201. assert result["action"] == "on"
  202. @pytest.mark.asyncio
  203. @pytest.mark.integration
  204. async def test_control_smart_plug_off(
  205. self, async_client: AsyncClient, smart_plug_factory, mock_tasmota_service, db_session
  206. ):
  207. """Verify smart plug can be turned off."""
  208. plug = await smart_plug_factory()
  209. response = await async_client.post(f"/api/v1/smart-plugs/{plug.id}/control", json={"action": "off"})
  210. assert response.status_code == 200
  211. result = response.json()
  212. assert result["success"] is True
  213. assert result["action"] == "off"
  214. @pytest.mark.asyncio
  215. @pytest.mark.integration
  216. async def test_control_smart_plug_toggle(
  217. self, async_client: AsyncClient, smart_plug_factory, mock_tasmota_service, db_session
  218. ):
  219. """Verify smart plug can be toggled."""
  220. plug = await smart_plug_factory()
  221. response = await async_client.post(f"/api/v1/smart-plugs/{plug.id}/control", json={"action": "toggle"})
  222. assert response.status_code == 200
  223. result = response.json()
  224. assert result["success"] is True
  225. assert result["action"] == "toggle"
  226. @pytest.mark.asyncio
  227. @pytest.mark.integration
  228. async def test_a_toggle_tells_the_mqtt_relay_the_resulting_state(
  229. self, async_client: AsyncClient, smart_plug_factory, mock_tasmota_service, db_session
  230. ):
  231. """The SpoolBuddy quick menu and the printer card switch plugs with
  232. toggle, so the state it ends in is read back and published."""
  233. from unittest.mock import AsyncMock, patch
  234. plug = await smart_plug_factory()
  235. mock_tasmota_service.get_status.return_value = {"state": "OFF", "reachable": True}
  236. with patch("backend.app.api.routes.smart_plugs.publish_plug_state", new_callable=AsyncMock) as publish:
  237. response = await async_client.post(f"/api/v1/smart-plugs/{plug.id}/control", json={"action": "toggle"})
  238. assert response.status_code == 200
  239. publish.assert_awaited_once()
  240. assert publish.await_args.kwargs["on"] is False
  241. @pytest.mark.asyncio
  242. @pytest.mark.integration
  243. async def test_control_invalid_action(self, async_client: AsyncClient, smart_plug_factory, db_session):
  244. """Verify invalid action returns error."""
  245. plug = await smart_plug_factory()
  246. response = await async_client.post(f"/api/v1/smart-plugs/{plug.id}/control", json={"action": "invalid"})
  247. # FastAPI returns 422 for pydantic validation errors
  248. assert response.status_code == 422
  249. # ========================================================================
  250. # Status endpoint
  251. # ========================================================================
  252. @pytest.mark.asyncio
  253. @pytest.mark.integration
  254. async def test_get_smart_plug_status(
  255. self, async_client: AsyncClient, smart_plug_factory, mock_tasmota_service, db_session
  256. ):
  257. """Verify smart plug status can be retrieved."""
  258. plug = await smart_plug_factory()
  259. response = await async_client.get(f"/api/v1/smart-plugs/{plug.id}/status")
  260. assert response.status_code == 200
  261. result = response.json()
  262. assert result["state"] == "ON"
  263. assert result["reachable"] is True
  264. # ========================================================================
  265. # Delete endpoint
  266. # ========================================================================
  267. @pytest.mark.asyncio
  268. @pytest.mark.integration
  269. async def test_delete_smart_plug(self, async_client: AsyncClient, smart_plug_factory, db_session):
  270. """Verify smart plug can be deleted."""
  271. plug = await smart_plug_factory()
  272. plug_id = plug.id
  273. response = await async_client.delete(f"/api/v1/smart-plugs/{plug_id}")
  274. assert response.status_code == 200
  275. # Verify deleted
  276. response = await async_client.get(f"/api/v1/smart-plugs/{plug_id}")
  277. assert response.status_code == 404
  278. @pytest.mark.asyncio
  279. @pytest.mark.integration
  280. async def test_delete_nonexistent_plug(self, async_client: AsyncClient):
  281. """Verify deleting non-existent plug returns 404."""
  282. response = await async_client.delete("/api/v1/smart-plugs/9999")
  283. assert response.status_code == 404
  284. # ========================================================================
  285. # Switchbar visibility
  286. # ========================================================================
  287. @pytest.mark.asyncio
  288. @pytest.mark.integration
  289. async def test_update_show_in_switchbar(self, async_client: AsyncClient, smart_plug_factory, db_session):
  290. """Verify show_in_switchbar toggle persists correctly."""
  291. plug = await smart_plug_factory(show_in_switchbar=False)
  292. response = await async_client.patch(f"/api/v1/smart-plugs/{plug.id}", json={"show_in_switchbar": True})
  293. assert response.status_code == 200
  294. assert response.json()["show_in_switchbar"] is True
  295. # Verify persistence
  296. response = await async_client.get(f"/api/v1/smart-plugs/{plug.id}")
  297. assert response.json()["show_in_switchbar"] is True
  298. # ========================================================================
  299. # Tasmota Discovery endpoints
  300. # ========================================================================
  301. @pytest.mark.asyncio
  302. @pytest.mark.integration
  303. async def test_tasmota_discovery_scan(self, async_client: AsyncClient):
  304. """Verify Tasmota discovery scan can be started."""
  305. response = await async_client.post("/api/v1/smart-plugs/discover/scan")
  306. assert response.status_code == 200
  307. data = response.json()
  308. assert "running" in data
  309. assert "scanned" in data
  310. assert "total" in data
  311. @pytest.mark.asyncio
  312. @pytest.mark.integration
  313. async def test_tasmota_discovery_status(self, async_client: AsyncClient):
  314. """Verify Tasmota discovery status endpoint works."""
  315. response = await async_client.get("/api/v1/smart-plugs/discover/status")
  316. assert response.status_code == 200
  317. data = response.json()
  318. assert "running" in data
  319. assert "scanned" in data
  320. assert "total" in data
  321. @pytest.mark.asyncio
  322. @pytest.mark.integration
  323. async def test_tasmota_discovery_devices(self, async_client: AsyncClient):
  324. """Verify Tasmota discovered devices endpoint works."""
  325. response = await async_client.get("/api/v1/smart-plugs/discover/devices")
  326. assert response.status_code == 200
  327. data = response.json()
  328. assert isinstance(data, list)
  329. @pytest.mark.asyncio
  330. @pytest.mark.integration
  331. async def test_tasmota_discovery_stop(self, async_client: AsyncClient):
  332. """Verify Tasmota discovery can be stopped."""
  333. response = await async_client.post("/api/v1/smart-plugs/discover/stop")
  334. assert response.status_code == 200
  335. data = response.json()
  336. assert "running" in data
  337. # ========================================================================
  338. # Home Assistant Integration tests
  339. # ========================================================================
  340. @pytest.mark.asyncio
  341. @pytest.mark.integration
  342. async def test_create_homeassistant_plug(self, async_client: AsyncClient):
  343. """Verify Home Assistant plug can be created."""
  344. data = {
  345. "name": "HA Plug",
  346. "plug_type": "homeassistant",
  347. "ha_entity_id": "switch.printer_plug",
  348. "enabled": True,
  349. "auto_on": True,
  350. "auto_off": False,
  351. }
  352. response = await async_client.post("/api/v1/smart-plugs/", json=data)
  353. assert response.status_code == 200
  354. result = response.json()
  355. assert result["name"] == "HA Plug"
  356. assert result["plug_type"] == "homeassistant"
  357. assert result["ha_entity_id"] == "switch.printer_plug"
  358. assert result["ip_address"] is None
  359. @pytest.mark.asyncio
  360. @pytest.mark.integration
  361. async def test_create_homeassistant_plug_missing_entity_id(self, async_client: AsyncClient):
  362. """Verify creating HA plug without entity_id fails."""
  363. data = {
  364. "name": "HA Plug",
  365. "plug_type": "homeassistant",
  366. # Missing ha_entity_id
  367. "enabled": True,
  368. }
  369. response = await async_client.post("/api/v1/smart-plugs/", json=data)
  370. assert response.status_code == 422 # Validation error
  371. @pytest.mark.asyncio
  372. @pytest.mark.integration
  373. async def test_create_tasmota_plug_missing_ip(self, async_client: AsyncClient):
  374. """Verify creating Tasmota plug without IP fails."""
  375. data = {
  376. "name": "Tasmota Plug",
  377. "plug_type": "tasmota",
  378. # Missing ip_address
  379. "enabled": True,
  380. }
  381. response = await async_client.post("/api/v1/smart-plugs/", json=data)
  382. assert response.status_code == 422 # Validation error
  383. @pytest.mark.asyncio
  384. @pytest.mark.integration
  385. async def test_ha_entities_endpoint_not_configured(self, async_client: AsyncClient):
  386. """Verify HA entities endpoint returns error when not configured."""
  387. response = await async_client.get("/api/v1/smart-plugs/ha/entities")
  388. assert response.status_code == 400
  389. assert "not configured" in response.json()["detail"].lower()
  390. @pytest.mark.asyncio
  391. @pytest.mark.integration
  392. async def test_update_plug_type(self, async_client: AsyncClient, smart_plug_factory, db_session):
  393. """Verify plug_type can be updated."""
  394. plug = await smart_plug_factory(plug_type="tasmota", ip_address="192.168.1.100")
  395. response = await async_client.patch(
  396. f"/api/v1/smart-plugs/{plug.id}",
  397. json={
  398. "plug_type": "homeassistant",
  399. "ha_entity_id": "switch.test",
  400. },
  401. )
  402. assert response.status_code == 200
  403. result = response.json()
  404. assert result["plug_type"] == "homeassistant"
  405. assert result["ha_entity_id"] == "switch.test"
  406. @pytest.mark.asyncio
  407. @pytest.mark.integration
  408. async def test_control_homeassistant_plug(
  409. self, async_client: AsyncClient, smart_plug_factory, mock_homeassistant_service, db_session
  410. ):
  411. """Verify HA smart plug can be controlled."""
  412. plug = await smart_plug_factory(plug_type="homeassistant", ha_entity_id="switch.test")
  413. response = await async_client.post(f"/api/v1/smart-plugs/{plug.id}/control", json={"action": "on"})
  414. assert response.status_code == 200
  415. result = response.json()
  416. assert result["success"] is True
  417. assert result["action"] == "on"
  418. @pytest.mark.asyncio
  419. @pytest.mark.integration
  420. async def test_get_homeassistant_plug_status(
  421. self, async_client: AsyncClient, smart_plug_factory, mock_homeassistant_service, db_session
  422. ):
  423. """Verify HA smart plug status can be retrieved."""
  424. plug = await smart_plug_factory(plug_type="homeassistant", ha_entity_id="switch.test")
  425. response = await async_client.get(f"/api/v1/smart-plugs/{plug.id}/status")
  426. assert response.status_code == 200
  427. result = response.json()
  428. assert result["state"] == "ON"
  429. assert result["reachable"] is True
  430. @pytest.mark.asyncio
  431. @pytest.mark.integration
  432. async def test_create_homeassistant_plug_with_energy_sensors(self, async_client: AsyncClient):
  433. """Verify HA plug can be created with energy sensor entities."""
  434. data = {
  435. "name": "HA Plug with Energy",
  436. "plug_type": "homeassistant",
  437. "ha_entity_id": "switch.printer_plug",
  438. "ha_power_entity": "sensor.printer_power",
  439. "ha_energy_today_entity": "sensor.printer_energy_today",
  440. "ha_energy_total_entity": "sensor.printer_energy_total",
  441. "enabled": True,
  442. }
  443. response = await async_client.post("/api/v1/smart-plugs/", json=data)
  444. assert response.status_code == 200
  445. result = response.json()
  446. assert result["ha_power_entity"] == "sensor.printer_power"
  447. assert result["ha_energy_today_entity"] == "sensor.printer_energy_today"
  448. assert result["ha_energy_total_entity"] == "sensor.printer_energy_total"
  449. @pytest.mark.asyncio
  450. @pytest.mark.integration
  451. async def test_update_ha_energy_sensor_entities(self, async_client: AsyncClient, smart_plug_factory, db_session):
  452. """Verify HA energy sensor entities can be updated."""
  453. plug = await smart_plug_factory(plug_type="homeassistant", ha_entity_id="switch.test")
  454. response = await async_client.patch(
  455. f"/api/v1/smart-plugs/{plug.id}",
  456. json={
  457. "ha_power_entity": "sensor.new_power",
  458. "ha_energy_today_entity": "sensor.new_today",
  459. "ha_energy_total_entity": "sensor.new_total",
  460. },
  461. )
  462. assert response.status_code == 200
  463. result = response.json()
  464. assert result["ha_power_entity"] == "sensor.new_power"
  465. assert result["ha_energy_today_entity"] == "sensor.new_today"
  466. assert result["ha_energy_total_entity"] == "sensor.new_total"
  467. @pytest.mark.asyncio
  468. @pytest.mark.integration
  469. async def test_ha_sensors_endpoint_not_configured(self, async_client: AsyncClient):
  470. """Verify HA sensors endpoint returns error when not configured."""
  471. response = await async_client.get("/api/v1/smart-plugs/ha/sensors")
  472. assert response.status_code == 400
  473. assert "not configured" in response.json()["detail"].lower()
  474. # ========================================================================
  475. # MQTT Integration tests
  476. # ========================================================================
  477. @pytest.mark.asyncio
  478. @pytest.mark.integration
  479. async def test_create_mqtt_plug(self, async_client: AsyncClient, mock_mqtt_smart_plug_service):
  480. """Verify MQTT plug can be created with topic and JSON paths."""
  481. data = {
  482. "name": "MQTT Energy Monitor",
  483. "plug_type": "mqtt",
  484. "mqtt_topic": "zigbee2mqtt/shelly-working-room",
  485. "mqtt_power_path": "power_l1",
  486. "mqtt_energy_path": "energy_l1",
  487. "mqtt_state_path": "state_l1",
  488. "mqtt_multiplier": 1.0,
  489. "enabled": True,
  490. }
  491. response = await async_client.post("/api/v1/smart-plugs/", json=data)
  492. assert response.status_code == 200
  493. result = response.json()
  494. assert result["name"] == "MQTT Energy Monitor"
  495. assert result["plug_type"] == "mqtt"
  496. assert result["mqtt_topic"] == "zigbee2mqtt/shelly-working-room"
  497. assert result["mqtt_power_path"] == "power_l1"
  498. assert result["mqtt_energy_path"] == "energy_l1"
  499. assert result["mqtt_state_path"] == "state_l1"
  500. assert result["mqtt_multiplier"] == 1.0
  501. assert result["ip_address"] is None
  502. @pytest.mark.asyncio
  503. @pytest.mark.integration
  504. async def test_create_mqtt_plug_missing_topic(self, async_client: AsyncClient):
  505. """Verify creating MQTT plug without topic fails."""
  506. data = {
  507. "name": "MQTT Plug",
  508. "plug_type": "mqtt",
  509. # Missing mqtt_topic
  510. "mqtt_power_path": "power",
  511. "enabled": True,
  512. }
  513. response = await async_client.post("/api/v1/smart-plugs/", json=data)
  514. assert response.status_code == 422 # Validation error
  515. @pytest.mark.asyncio
  516. @pytest.mark.integration
  517. async def test_create_mqtt_plug_missing_topic(self, async_client: AsyncClient):
  518. """Verify creating MQTT plug without any topic fails."""
  519. data = {
  520. "name": "MQTT Plug",
  521. "plug_type": "mqtt",
  522. # No topic configured at all
  523. "enabled": True,
  524. }
  525. response = await async_client.post("/api/v1/smart-plugs/", json=data)
  526. assert response.status_code == 422 # Validation error
  527. @pytest.mark.asyncio
  528. @pytest.mark.integration
  529. async def test_create_mqtt_plug_with_multiplier(self, async_client: AsyncClient, mock_mqtt_smart_plug_service):
  530. """Verify MQTT plug can use multiplier for unit conversion."""
  531. data = {
  532. "name": "MQTT mW to W",
  533. "plug_type": "mqtt",
  534. "mqtt_topic": "sensors/power",
  535. "mqtt_power_path": "power_mw",
  536. "mqtt_multiplier": 0.001, # Convert mW to W
  537. "enabled": True,
  538. }
  539. response = await async_client.post("/api/v1/smart-plugs/", json=data)
  540. assert response.status_code == 200
  541. result = response.json()
  542. assert result["mqtt_multiplier"] == 0.001
  543. @pytest.mark.asyncio
  544. @pytest.mark.integration
  545. async def test_control_mqtt_plug_returns_error(self, async_client: AsyncClient, smart_plug_factory, db_session):
  546. """Verify MQTT plugs cannot be controlled (monitor-only)."""
  547. plug = await smart_plug_factory(
  548. plug_type="mqtt",
  549. mqtt_topic="test/topic",
  550. mqtt_power_path="power",
  551. )
  552. response = await async_client.post(f"/api/v1/smart-plugs/{plug.id}/control", json={"action": "on"})
  553. assert response.status_code == 400
  554. assert "monitor-only" in response.json()["detail"].lower()
  555. @pytest.mark.asyncio
  556. @pytest.mark.integration
  557. async def test_update_mqtt_plug_topic(self, async_client: AsyncClient, smart_plug_factory, db_session):
  558. """Verify MQTT plug topic can be updated."""
  559. plug = await smart_plug_factory(
  560. plug_type="mqtt",
  561. mqtt_topic="old/topic",
  562. mqtt_power_path="power",
  563. )
  564. response = await async_client.patch(
  565. f"/api/v1/smart-plugs/{plug.id}",
  566. json={
  567. "mqtt_topic": "new/topic",
  568. "mqtt_power_path": "new_power",
  569. },
  570. )
  571. assert response.status_code == 200
  572. result = response.json()
  573. assert result["mqtt_topic"] == "new/topic"
  574. assert result["mqtt_power_path"] == "new_power"
  575. # ========================================================================
  576. # Enhanced MQTT Integration tests (separate topics per data type)
  577. # ========================================================================
  578. @pytest.mark.asyncio
  579. @pytest.mark.integration
  580. async def test_create_mqtt_plug_with_separate_topics(self, async_client: AsyncClient, mock_mqtt_smart_plug_service):
  581. """Verify MQTT plug can be created with separate topics for power, energy, and state."""
  582. data = {
  583. "name": "MQTT Separate Topics",
  584. "plug_type": "mqtt",
  585. "mqtt_power_topic": "zigbee/power",
  586. "mqtt_power_path": "power_l1",
  587. "mqtt_power_multiplier": 0.001,
  588. "mqtt_energy_topic": "zigbee/energy",
  589. "mqtt_energy_path": "energy_total",
  590. "mqtt_energy_multiplier": 1.0,
  591. "mqtt_state_topic": "zigbee/state",
  592. "mqtt_state_path": "state",
  593. "mqtt_state_on_value": "ON",
  594. "enabled": True,
  595. }
  596. response = await async_client.post("/api/v1/smart-plugs/", json=data)
  597. assert response.status_code == 200
  598. result = response.json()
  599. assert result["name"] == "MQTT Separate Topics"
  600. assert result["plug_type"] == "mqtt"
  601. # Power fields
  602. assert result["mqtt_power_topic"] == "zigbee/power"
  603. assert result["mqtt_power_path"] == "power_l1"
  604. assert result["mqtt_power_multiplier"] == 0.001
  605. # Energy fields
  606. assert result["mqtt_energy_topic"] == "zigbee/energy"
  607. assert result["mqtt_energy_path"] == "energy_total"
  608. assert result["mqtt_energy_multiplier"] == 1.0
  609. # State fields
  610. assert result["mqtt_state_topic"] == "zigbee/state"
  611. assert result["mqtt_state_path"] == "state"
  612. assert result["mqtt_state_on_value"] == "ON"
  613. @pytest.mark.asyncio
  614. @pytest.mark.integration
  615. async def test_create_mqtt_plug_energy_only(self, async_client: AsyncClient, mock_mqtt_smart_plug_service):
  616. """Verify MQTT plug can be created with only energy monitoring."""
  617. data = {
  618. "name": "Energy Only Monitor",
  619. "plug_type": "mqtt",
  620. "mqtt_energy_topic": "sensors/energy",
  621. "mqtt_energy_path": "kwh",
  622. "mqtt_energy_multiplier": 0.001, # Wh to kWh
  623. "enabled": True,
  624. }
  625. response = await async_client.post("/api/v1/smart-plugs/", json=data)
  626. assert response.status_code == 200
  627. result = response.json()
  628. assert result["mqtt_energy_topic"] == "sensors/energy"
  629. assert result["mqtt_energy_path"] == "kwh"
  630. assert result["mqtt_energy_multiplier"] == 0.001
  631. @pytest.mark.asyncio
  632. @pytest.mark.integration
  633. async def test_create_mqtt_plug_state_only(self, async_client: AsyncClient, mock_mqtt_smart_plug_service):
  634. """Verify MQTT plug can be created with only state monitoring."""
  635. data = {
  636. "name": "State Only Monitor",
  637. "plug_type": "mqtt",
  638. "mqtt_state_topic": "switches/outlet",
  639. "mqtt_state_path": "state",
  640. "mqtt_state_on_value": "true",
  641. "enabled": True,
  642. }
  643. response = await async_client.post("/api/v1/smart-plugs/", json=data)
  644. assert response.status_code == 200
  645. result = response.json()
  646. assert result["mqtt_state_topic"] == "switches/outlet"
  647. assert result["mqtt_state_path"] == "state"
  648. assert result["mqtt_state_on_value"] == "true"
  649. @pytest.mark.asyncio
  650. @pytest.mark.integration
  651. async def test_create_mqtt_plug_topic_only_succeeds(self, async_client: AsyncClient, mock_mqtt_smart_plug_service):
  652. """Verify creating MQTT plug with topic only (no path) succeeds for raw values."""
  653. data = {
  654. "name": "Raw MQTT Plug",
  655. "plug_type": "mqtt",
  656. # Topic only, no path - valid for raw numeric MQTT values
  657. "mqtt_power_topic": "zigbee/power",
  658. "enabled": True,
  659. }
  660. response = await async_client.post("/api/v1/smart-plugs/", json=data)
  661. assert response.status_code == 200 # Should succeed
  662. result = response.json()
  663. assert result["mqtt_power_topic"] == "zigbee/power"
  664. assert result["mqtt_power_path"] is None
  665. @pytest.mark.asyncio
  666. @pytest.mark.integration
  667. async def test_update_mqtt_plug_separate_multipliers(
  668. self, async_client: AsyncClient, smart_plug_factory, db_session, mock_mqtt_smart_plug_service
  669. ):
  670. """Verify MQTT plug multipliers can be updated separately."""
  671. plug = await smart_plug_factory(
  672. plug_type="mqtt",
  673. mqtt_power_topic="test/power",
  674. mqtt_power_path="power",
  675. mqtt_power_multiplier=1.0,
  676. mqtt_energy_topic="test/energy",
  677. mqtt_energy_path="energy",
  678. mqtt_energy_multiplier=1.0,
  679. )
  680. response = await async_client.patch(
  681. f"/api/v1/smart-plugs/{plug.id}",
  682. json={
  683. "mqtt_power_multiplier": 0.001, # Change power multiplier only
  684. "mqtt_energy_multiplier": 0.001, # Change energy multiplier only
  685. },
  686. )
  687. assert response.status_code == 200
  688. result = response.json()
  689. assert result["mqtt_power_multiplier"] == 0.001
  690. assert result["mqtt_energy_multiplier"] == 0.001
  691. # ========================================================================
  692. # REST Smart Plug Integration tests
  693. # ========================================================================
  694. @pytest.mark.asyncio
  695. @pytest.mark.integration
  696. async def test_create_rest_plug(self, async_client: AsyncClient):
  697. """Verify REST plug can be created with ON/OFF URLs."""
  698. data = {
  699. "name": "REST Plug",
  700. "plug_type": "rest",
  701. "rest_on_url": "http://openhab:8080/rest/items/MyPlug",
  702. "rest_on_body": "ON",
  703. "rest_off_url": "http://openhab:8080/rest/items/MyPlug",
  704. "rest_off_body": "OFF",
  705. "rest_method": "POST",
  706. "enabled": True,
  707. }
  708. response = await async_client.post("/api/v1/smart-plugs/", json=data)
  709. assert response.status_code == 200
  710. result = response.json()
  711. assert result["name"] == "REST Plug"
  712. assert result["plug_type"] == "rest"
  713. assert result["rest_on_url"] == "http://openhab:8080/rest/items/MyPlug"
  714. assert result["rest_on_body"] == "ON"
  715. assert result["rest_off_url"] == "http://openhab:8080/rest/items/MyPlug"
  716. assert result["rest_off_body"] == "OFF"
  717. assert result["rest_method"] == "POST"
  718. assert result["ip_address"] is None
  719. assert result["ha_entity_id"] is None
  720. @pytest.mark.asyncio
  721. @pytest.mark.integration
  722. async def test_create_rest_plug_on_url_only(self, async_client: AsyncClient):
  723. """Verify REST plug can be created with only ON URL."""
  724. data = {
  725. "name": "REST ON Only",
  726. "plug_type": "rest",
  727. "rest_on_url": "http://iobroker:8087/set/plug?value=true",
  728. "rest_method": "GET",
  729. "enabled": True,
  730. }
  731. response = await async_client.post("/api/v1/smart-plugs/", json=data)
  732. assert response.status_code == 200
  733. result = response.json()
  734. assert result["rest_on_url"] == "http://iobroker:8087/set/plug?value=true"
  735. assert result["rest_off_url"] is None
  736. assert result["rest_method"] == "GET"
  737. @pytest.mark.asyncio
  738. @pytest.mark.integration
  739. async def test_create_rest_plug_missing_urls_fails(self, async_client: AsyncClient):
  740. """Verify creating REST plug without any URL fails."""
  741. data = {
  742. "name": "REST Plug",
  743. "plug_type": "rest",
  744. "enabled": True,
  745. }
  746. response = await async_client.post("/api/v1/smart-plugs/", json=data)
  747. assert response.status_code == 422 # Validation error
  748. @pytest.mark.asyncio
  749. @pytest.mark.integration
  750. async def test_create_rest_plug_with_status_and_energy(self, async_client: AsyncClient):
  751. """Verify REST plug with status polling and energy monitoring."""
  752. data = {
  753. "name": "REST Full",
  754. "plug_type": "rest",
  755. "rest_on_url": "http://ha:8080/api/plug/on",
  756. "rest_off_url": "http://ha:8080/api/plug/off",
  757. "rest_method": "POST",
  758. "rest_headers": '{"Authorization": "Bearer test-token"}',
  759. "rest_status_url": "http://ha:8080/api/plug/status",
  760. "rest_status_path": "state",
  761. "rest_status_on_value": "ON",
  762. "rest_power_path": "power.current",
  763. "rest_energy_path": "energy.today",
  764. "enabled": True,
  765. }
  766. response = await async_client.post("/api/v1/smart-plugs/", json=data)
  767. assert response.status_code == 200
  768. result = response.json()
  769. assert result["rest_headers"] == "********" # write-only
  770. assert result["rest_status_url"] == "http://ha:8080/api/plug/status"
  771. assert result["rest_status_path"] == "state"
  772. assert result["rest_status_on_value"] == "ON"
  773. assert result["rest_power_path"] == "power.current"
  774. assert result["rest_energy_path"] == "energy.today"
  775. @pytest.mark.asyncio
  776. @pytest.mark.integration
  777. async def test_update_rest_plug(self, async_client: AsyncClient, smart_plug_factory, db_session):
  778. """Verify REST plug fields can be updated."""
  779. plug = await smart_plug_factory(plug_type="rest")
  780. response = await async_client.patch(
  781. f"/api/v1/smart-plugs/{plug.id}",
  782. json={
  783. "rest_on_url": "http://new-host:8080/on",
  784. "rest_method": "PUT",
  785. },
  786. )
  787. assert response.status_code == 200
  788. result = response.json()
  789. assert result["rest_on_url"] == "http://new-host:8080/on"
  790. assert result["rest_method"] == "PUT"
  791. @pytest.mark.asyncio
  792. @pytest.mark.integration
  793. async def test_rest_plug_is_controllable(self, async_client: AsyncClient, smart_plug_factory, db_session):
  794. """Verify REST plugs can be controlled (not monitor-only like MQTT)."""
  795. plug = await smart_plug_factory(plug_type="rest")
  796. # REST plugs should NOT return 400 like MQTT plugs
  797. response = await async_client.post(
  798. f"/api/v1/smart-plugs/{plug.id}/control",
  799. json={"action": "on"},
  800. )
  801. # Should attempt to send the request (may fail with 503 since URL is not real,
  802. # but should NOT return 400 "monitor-only")
  803. assert response.status_code != 400