test_notifications_api.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697
  1. """Integration tests for Notifications API endpoints.
  2. Tests the full request/response cycle for /api/v1/notifications/ endpoints.
  3. """
  4. import pytest
  5. from httpx import AsyncClient
  6. from sqlalchemy import text
  7. class TestNotificationsAPI:
  8. """Integration tests for /api/v1/notifications/ endpoints."""
  9. # ========================================================================
  10. # List endpoints
  11. # ========================================================================
  12. @pytest.mark.asyncio
  13. @pytest.mark.integration
  14. async def test_list_notification_providers_empty(self, async_client: AsyncClient):
  15. """Verify empty list is returned when no providers exist."""
  16. response = await async_client.get("/api/v1/notifications/")
  17. assert response.status_code == 200
  18. assert response.json() == []
  19. @pytest.mark.asyncio
  20. @pytest.mark.integration
  21. async def test_list_notification_providers_with_data(
  22. self, async_client: AsyncClient, notification_provider_factory, db_session
  23. ):
  24. """Verify list returns existing providers."""
  25. _provider = await notification_provider_factory(name="Test Provider")
  26. response = await async_client.get("/api/v1/notifications/")
  27. assert response.status_code == 200
  28. data = response.json()
  29. assert len(data) >= 1
  30. assert any(p["name"] == "Test Provider" for p in data)
  31. @pytest.mark.asyncio
  32. @pytest.mark.integration
  33. async def test_a_row_with_null_event_flags_is_still_listable(
  34. self, async_client: AsyncClient, notification_provider_factory, db_session
  35. ):
  36. """A legacy row whose flag columns were never backfilled must not 500 the list.
  37. Every on_* column is nullable with no server default, so a row created
  38. before a flag existed keeps NULL there until a migration backfills it --
  39. and #1184's ALTER ... DEFAULT false silently did not, on any install
  40. where create_all() had already added the column. Declaring those flags
  41. on the response schema in #2827 turned those NULLs into a hard failure:
  42. pydantic rejects None for a bool, so every provider row failed at once
  43. and the list came back empty to the UI.
  44. Written against the two flags that actually broke, but the whole set is
  45. checked -- the next flag added to the schema has the same exposure.
  46. """
  47. provider = await notification_provider_factory(name="Legacy Provider")
  48. flags = ["on_stock_reorder_alert", "on_stock_break_alert"]
  49. await db_session.execute(
  50. text(f"UPDATE notification_providers SET {', '.join(f'{f} = NULL' for f in flags)} WHERE id = :id"),
  51. {"id": provider.id},
  52. )
  53. await db_session.commit()
  54. stored = await db_session.execute(
  55. text(f"SELECT {', '.join(flags)} FROM notification_providers WHERE id = :id"), {"id": provider.id}
  56. )
  57. assert all(value is None for value in stored.one()), "row under test must actually hold NULLs"
  58. response = await async_client.get("/api/v1/notifications/")
  59. assert response.status_code == 200
  60. listed = next(p for p in response.json() if p["name"] == "Legacy Provider")
  61. # Off, not the field default: the sender selects on `.is_(True)`, so a
  62. # NULL flag never sent anything, and repairing the read must not switch
  63. # a notification on.
  64. assert all(listed[flag] is False for flag in flags)
  65. # The single-provider route reads through the same schema.
  66. single = await async_client.get(f"/api/v1/notifications/{provider.id}")
  67. assert single.status_code == 200
  68. assert all(single.json()[flag] is False for flag in flags)
  69. # ========================================================================
  70. # Create endpoints
  71. # ========================================================================
  72. @pytest.mark.asyncio
  73. @pytest.mark.integration
  74. async def test_create_callmebot_provider(self, async_client: AsyncClient):
  75. """Verify callmebot notification provider can be created."""
  76. data = {
  77. "name": "Test CallMeBot",
  78. "provider_type": "callmebot",
  79. "enabled": True,
  80. "config": {"phone_number": "+1234567890", "api_key": "test-api-key"},
  81. "on_print_start": True,
  82. "on_print_complete": True,
  83. "on_print_failed": True,
  84. "on_print_stopped": False,
  85. }
  86. response = await async_client.post("/api/v1/notifications/", json=data)
  87. assert response.status_code == 200
  88. result = response.json()
  89. assert result["name"] == "Test CallMeBot"
  90. assert result["provider_type"] == "callmebot"
  91. assert result["on_print_start"] is True
  92. assert result["on_print_stopped"] is False
  93. @pytest.mark.asyncio
  94. @pytest.mark.integration
  95. async def test_create_ntfy_provider(self, async_client: AsyncClient):
  96. """Verify ntfy notification provider can be created."""
  97. data = {
  98. "name": "Test Ntfy",
  99. "provider_type": "ntfy",
  100. "enabled": True,
  101. "config": {
  102. "server": "https://ntfy.sh",
  103. "topic": "test-topic",
  104. },
  105. "on_print_complete": True,
  106. }
  107. response = await async_client.post("/api/v1/notifications/", json=data)
  108. assert response.status_code == 200
  109. result = response.json()
  110. assert result["provider_type"] == "ntfy"
  111. @pytest.mark.asyncio
  112. @pytest.mark.integration
  113. async def test_create_provider_with_printer(self, async_client: AsyncClient, printer_factory, db_session):
  114. """Verify provider can be linked to specific printer."""
  115. printer = await printer_factory(name="Test Printer")
  116. data = {
  117. "name": "Printer Ntfy",
  118. "provider_type": "ntfy",
  119. "config": {"server": "https://ntfy.sh", "topic": "test-topic"},
  120. "printer_id": printer.id,
  121. }
  122. response = await async_client.post("/api/v1/notifications/", json=data)
  123. assert response.status_code == 200
  124. result = response.json()
  125. assert result["printer_id"] == printer.id
  126. # ========================================================================
  127. # Get single endpoint
  128. # ========================================================================
  129. @pytest.mark.asyncio
  130. @pytest.mark.integration
  131. async def test_get_notification_provider(
  132. self, async_client: AsyncClient, notification_provider_factory, db_session
  133. ):
  134. """Verify single provider can be retrieved."""
  135. provider = await notification_provider_factory(name="Get Test Provider")
  136. response = await async_client.get(f"/api/v1/notifications/{provider.id}")
  137. assert response.status_code == 200
  138. result = response.json()
  139. assert result["id"] == provider.id
  140. assert result["name"] == "Get Test Provider"
  141. @pytest.mark.asyncio
  142. @pytest.mark.integration
  143. async def test_get_provider_not_found(self, async_client: AsyncClient):
  144. """Verify 404 for non-existent provider."""
  145. response = await async_client.get("/api/v1/notifications/9999")
  146. assert response.status_code == 404
  147. # ========================================================================
  148. # Update endpoints (CRITICAL - toggle persistence)
  149. # ========================================================================
  150. @pytest.mark.asyncio
  151. @pytest.mark.integration
  152. async def test_update_event_toggles(self, async_client: AsyncClient, notification_provider_factory, db_session):
  153. """CRITICAL: Verify notification event toggles persist correctly."""
  154. provider = await notification_provider_factory(
  155. on_print_start=True,
  156. on_print_complete=True,
  157. on_print_stopped=False,
  158. )
  159. # Toggle on_print_stopped to True
  160. response = await async_client.patch(f"/api/v1/notifications/{provider.id}", json={"on_print_stopped": True})
  161. assert response.status_code == 200
  162. assert response.json()["on_print_stopped"] is True
  163. # Verify change persisted
  164. response = await async_client.get(f"/api/v1/notifications/{provider.id}")
  165. assert response.json()["on_print_stopped"] is True
  166. @pytest.mark.asyncio
  167. @pytest.mark.integration
  168. async def test_update_ams_alarm_toggles(self, async_client: AsyncClient, notification_provider_factory, db_session):
  169. """CRITICAL: Verify AMS alarm toggles persist correctly."""
  170. provider = await notification_provider_factory(
  171. on_ams_humidity_high=False,
  172. on_ams_temperature_high=False,
  173. )
  174. # Enable AMS alarms
  175. response = await async_client.patch(
  176. f"/api/v1/notifications/{provider.id}",
  177. json={
  178. "on_ams_humidity_high": True,
  179. "on_ams_temperature_high": True,
  180. },
  181. )
  182. assert response.status_code == 200
  183. result = response.json()
  184. assert result["on_ams_humidity_high"] is True
  185. assert result["on_ams_temperature_high"] is True
  186. # Verify persistence
  187. response = await async_client.get(f"/api/v1/notifications/{provider.id}")
  188. result = response.json()
  189. assert result["on_ams_humidity_high"] is True
  190. assert result["on_ams_temperature_high"] is True
  191. @pytest.mark.asyncio
  192. @pytest.mark.integration
  193. async def test_enable_disable_provider(self, async_client: AsyncClient, notification_provider_factory, db_session):
  194. """Verify provider can be enabled/disabled."""
  195. provider = await notification_provider_factory(enabled=True)
  196. # Disable
  197. response = await async_client.patch(f"/api/v1/notifications/{provider.id}", json={"enabled": False})
  198. assert response.status_code == 200
  199. assert response.json()["enabled"] is False
  200. # Enable
  201. response = await async_client.patch(f"/api/v1/notifications/{provider.id}", json={"enabled": True})
  202. assert response.status_code == 200
  203. assert response.json()["enabled"] is True
  204. @pytest.mark.asyncio
  205. @pytest.mark.integration
  206. async def test_update_quiet_hours(self, async_client: AsyncClient, notification_provider_factory, db_session):
  207. """Verify quiet hours can be configured."""
  208. provider = await notification_provider_factory(quiet_hours_enabled=False)
  209. response = await async_client.patch(
  210. f"/api/v1/notifications/{provider.id}",
  211. json={
  212. "quiet_hours_enabled": True,
  213. "quiet_hours_start": "22:00",
  214. "quiet_hours_end": "07:00",
  215. },
  216. )
  217. assert response.status_code == 200
  218. result = response.json()
  219. assert result["quiet_hours_enabled"] is True
  220. assert result["quiet_hours_start"] == "22:00"
  221. assert result["quiet_hours_end"] == "07:00"
  222. @pytest.mark.asyncio
  223. @pytest.mark.integration
  224. async def test_update_daily_digest(self, async_client: AsyncClient, notification_provider_factory, db_session):
  225. """Verify daily digest can be configured."""
  226. provider = await notification_provider_factory(daily_digest_enabled=False)
  227. response = await async_client.patch(
  228. f"/api/v1/notifications/{provider.id}",
  229. json={
  230. "daily_digest_enabled": True,
  231. "daily_digest_time": "09:00",
  232. },
  233. )
  234. assert response.status_code == 200
  235. result = response.json()
  236. assert result["daily_digest_enabled"] is True
  237. assert result["daily_digest_time"] == "09:00"
  238. @pytest.mark.asyncio
  239. @pytest.mark.integration
  240. async def test_update_multiple_event_toggles(
  241. self, async_client: AsyncClient, notification_provider_factory, db_session
  242. ):
  243. """Verify multiple event toggles can be updated at once."""
  244. provider = await notification_provider_factory(
  245. on_print_start=True,
  246. on_print_complete=True,
  247. on_print_failed=True,
  248. on_print_stopped=False,
  249. on_printer_offline=False,
  250. )
  251. response = await async_client.patch(
  252. f"/api/v1/notifications/{provider.id}",
  253. json={
  254. "on_print_start": False,
  255. "on_print_stopped": True,
  256. "on_printer_offline": True,
  257. },
  258. )
  259. assert response.status_code == 200
  260. result = response.json()
  261. assert result["on_print_start"] is False
  262. assert result["on_print_stopped"] is True
  263. assert result["on_printer_offline"] is True
  264. # Unchanged fields should remain
  265. assert result["on_print_complete"] is True
  266. assert result["on_print_failed"] is True
  267. # ========================================================================
  268. # Test notification endpoint
  269. # ========================================================================
  270. @pytest.mark.asyncio
  271. @pytest.mark.integration
  272. async def test_test_notification(
  273. self, async_client: AsyncClient, notification_provider_factory, mock_httpx_client, db_session
  274. ):
  275. """Verify test notification can be sent."""
  276. provider = await notification_provider_factory()
  277. response = await async_client.post(f"/api/v1/notifications/{provider.id}/test")
  278. assert response.status_code == 200
  279. result = response.json()
  280. assert result["success"] is True
  281. @pytest.mark.asyncio
  282. @pytest.mark.integration
  283. async def test_test_notification_disabled_provider(
  284. self, async_client: AsyncClient, notification_provider_factory, db_session
  285. ):
  286. """Verify test notification works even for disabled provider."""
  287. provider = await notification_provider_factory(enabled=False)
  288. response = await async_client.post(f"/api/v1/notifications/{provider.id}/test")
  289. # Test should still work for disabled providers
  290. assert response.status_code == 200
  291. # ========================================================================
  292. # Delete endpoint
  293. # ========================================================================
  294. @pytest.mark.asyncio
  295. @pytest.mark.integration
  296. async def test_delete_notification_provider(
  297. self, async_client: AsyncClient, notification_provider_factory, db_session
  298. ):
  299. """Verify notification provider can be deleted."""
  300. provider = await notification_provider_factory()
  301. provider_id = provider.id
  302. response = await async_client.delete(f"/api/v1/notifications/{provider_id}")
  303. assert response.status_code == 200
  304. # Verify deleted
  305. response = await async_client.get(f"/api/v1/notifications/{provider_id}")
  306. assert response.status_code == 404
  307. @pytest.mark.asyncio
  308. @pytest.mark.integration
  309. async def test_delete_nonexistent_provider(self, async_client: AsyncClient):
  310. """Verify deleting non-existent provider returns 404."""
  311. response = await async_client.delete("/api/v1/notifications/9999")
  312. assert response.status_code == 404
  313. @pytest.mark.asyncio
  314. @pytest.mark.integration
  315. async def test_create_provider_with_first_layer_complete(self, async_client: AsyncClient):
  316. """Verify first layer complete toggle persists on create."""
  317. data = {
  318. "name": "First Layer Test",
  319. "provider_type": "ntfy",
  320. "config": {"server": "https://ntfy.sh", "topic": "test"},
  321. "on_first_layer_complete": True,
  322. }
  323. response = await async_client.post("/api/v1/notifications/", json=data)
  324. assert response.status_code == 200
  325. result = response.json()
  326. assert result["on_first_layer_complete"] is True
  327. @pytest.mark.asyncio
  328. @pytest.mark.integration
  329. async def test_update_first_layer_complete_toggle(
  330. self, async_client: AsyncClient, notification_provider_factory, db_session
  331. ):
  332. """CRITICAL: Verify first layer complete toggle persists correctly."""
  333. provider = await notification_provider_factory(on_first_layer_complete=False)
  334. response = await async_client.patch(
  335. f"/api/v1/notifications/{provider.id}",
  336. json={"on_first_layer_complete": True},
  337. )
  338. assert response.status_code == 200
  339. assert response.json()["on_first_layer_complete"] is True
  340. # Verify persistence
  341. response = await async_client.get(f"/api/v1/notifications/{provider.id}")
  342. assert response.json()["on_first_layer_complete"] is True
  343. @pytest.mark.asyncio
  344. @pytest.mark.integration
  345. async def test_first_layer_complete_independent_from_other_toggles(
  346. self, async_client: AsyncClient, notification_provider_factory, db_session
  347. ):
  348. """Verify first layer complete is independent from bed cooled and print complete."""
  349. provider = await notification_provider_factory(
  350. on_print_complete=True,
  351. on_bed_cooled=False,
  352. on_first_layer_complete=True,
  353. )
  354. response = await async_client.get(f"/api/v1/notifications/{provider.id}")
  355. result = response.json()
  356. assert result["on_print_complete"] is True
  357. assert result["on_bed_cooled"] is False
  358. assert result["on_first_layer_complete"] is True
  359. @pytest.mark.asyncio
  360. @pytest.mark.integration
  361. async def test_create_provider_with_missing_spool_assignment_toggle(self, async_client: AsyncClient):
  362. """Verify missing spool assignment toggle persists on create."""
  363. data = {
  364. "name": "Missing Spool Assignment Test",
  365. "provider_type": "ntfy",
  366. "config": {"server": "https://ntfy.sh", "topic": "test"},
  367. "on_print_missing_spool_assignment": True,
  368. }
  369. response = await async_client.post("/api/v1/notifications/", json=data)
  370. assert response.status_code == 200
  371. result = response.json()
  372. assert result["on_print_missing_spool_assignment"] is True
  373. @pytest.mark.asyncio
  374. @pytest.mark.integration
  375. async def test_update_missing_spool_assignment_toggle(
  376. self, async_client: AsyncClient, notification_provider_factory, db_session
  377. ):
  378. """CRITICAL: Verify missing spool assignment toggle persists correctly."""
  379. provider = await notification_provider_factory(on_print_missing_spool_assignment=False)
  380. response = await async_client.patch(
  381. f"/api/v1/notifications/{provider.id}",
  382. json={"on_print_missing_spool_assignment": True},
  383. )
  384. assert response.status_code == 200
  385. assert response.json()["on_print_missing_spool_assignment"] is True
  386. response = await async_client.get(f"/api/v1/notifications/{provider.id}")
  387. assert response.json()["on_print_missing_spool_assignment"] is True
  388. @pytest.mark.asyncio
  389. @pytest.mark.integration
  390. async def test_update_billing_charge_failed_toggle(
  391. self, async_client: AsyncClient, notification_provider_factory, db_session
  392. ):
  393. """Billing alerts can be enabled independently for each provider."""
  394. provider = await notification_provider_factory(on_billing_charge_failed=True)
  395. response = await async_client.patch(
  396. f"/api/v1/notifications/{provider.id}",
  397. json={"on_billing_charge_failed": False},
  398. )
  399. assert response.status_code == 200
  400. assert response.json()["on_billing_charge_failed"] is False
  401. response = await async_client.get(f"/api/v1/notifications/{provider.id}")
  402. assert response.json()["on_billing_charge_failed"] is False
  403. # Per-event toggles that live only in these hand-maintained field maps.
  404. #
  405. # These have to be exercised through the route, not the ORM: both
  406. # directions of notifications.py are hand-maintained field-by-field maps,
  407. # and a column missing from either one is invisible to any test that
  408. # builds NotificationProvider objects directly. The failure mode is
  409. # silent — NotificationProviderResponse inherits the field from
  410. # NotificationProviderBase, so FastAPI serialises the schema default
  411. # (False) instead of raising on the missing key, and the UI reads a
  412. # toggle that is on in the database as off.
  413. #
  414. # The Home Assistant pair (#1148, #2824) was the first to be caught this
  415. # way. The stock pair was caught by the same reasoning: its columns, its
  416. # templates, its sending code and its whole UI shipped, but the schema
  417. # never carried the fields, so Pydantic dropped them from every payload and
  418. # the toggles could not be turned on at all.
  419. @pytest.mark.asyncio
  420. @pytest.mark.integration
  421. @pytest.mark.parametrize(
  422. "field",
  423. ["on_ha_sensor_alert", "on_location_ha_sensor_alert", "on_stock_reorder_alert", "on_stock_break_alert"],
  424. )
  425. async def test_create_persists_and_returns_the_toggle(self, async_client: AsyncClient, field: str):
  426. response = await async_client.post(
  427. "/api/v1/notifications/",
  428. json={
  429. "name": "Sensor Alert Test",
  430. "provider_type": "ntfy",
  431. "config": {"server": "https://ntfy.sh", "topic": "test"},
  432. field: True,
  433. },
  434. )
  435. assert response.status_code == 200
  436. assert response.json()[field] is True
  437. # Re-read it: a value dropped by the create constructor but echoed
  438. # from the request body would still pass the assertion above.
  439. provider_id = response.json()["id"]
  440. response = await async_client.get(f"/api/v1/notifications/{provider_id}")
  441. assert response.json()[field] is True
  442. @pytest.mark.asyncio
  443. @pytest.mark.integration
  444. @pytest.mark.parametrize(
  445. "field",
  446. ["on_ha_sensor_alert", "on_location_ha_sensor_alert", "on_stock_reorder_alert", "on_stock_break_alert"],
  447. )
  448. async def test_patch_is_reflected_by_every_read_route(
  449. self, async_client: AsyncClient, notification_provider_factory, field: str
  450. ):
  451. """PATCH already persisted (generic setattr loop) — the reads were the broken half."""
  452. provider = await notification_provider_factory(**{field: False})
  453. response = await async_client.patch(f"/api/v1/notifications/{provider.id}", json={field: True})
  454. assert response.status_code == 200
  455. assert response.json()[field] is True
  456. response = await async_client.get(f"/api/v1/notifications/{provider.id}")
  457. assert response.json()[field] is True
  458. response = await async_client.get("/api/v1/notifications/")
  459. listed = next(p for p in response.json() if p["id"] == provider.id)
  460. assert listed[field] is True
  461. class TestNotificationTemplatesAPI:
  462. """Integration tests for /api/v1/notification-templates/ endpoints."""
  463. @pytest.fixture
  464. async def seeded_templates(self, db_session):
  465. """Seed notification templates for tests."""
  466. from backend.app.models.notification_template import DEFAULT_TEMPLATES, NotificationTemplate
  467. templates = []
  468. for template_data in DEFAULT_TEMPLATES:
  469. template = NotificationTemplate(**template_data)
  470. db_session.add(template)
  471. templates.append(template)
  472. await db_session.commit()
  473. for template in templates:
  474. await db_session.refresh(template)
  475. return templates
  476. @pytest.mark.asyncio
  477. @pytest.mark.integration
  478. async def test_list_templates(self, async_client: AsyncClient, seeded_templates):
  479. """Verify default templates are seeded and can be listed."""
  480. response = await async_client.get("/api/v1/notification-templates/")
  481. assert response.status_code == 200
  482. templates = response.json()
  483. # Should have default templates seeded
  484. assert len(templates) >= 1
  485. @pytest.mark.asyncio
  486. @pytest.mark.integration
  487. async def test_get_template_by_id(self, async_client: AsyncClient, seeded_templates):
  488. """Verify template can be retrieved by ID."""
  489. # Get first template ID from seeded data
  490. template_id = seeded_templates[0].id
  491. response = await async_client.get(f"/api/v1/notification-templates/{template_id}")
  492. assert response.status_code == 200
  493. template = response.json()
  494. assert template["id"] == template_id
  495. @pytest.mark.asyncio
  496. @pytest.mark.integration
  497. async def test_update_template(self, async_client: AsyncClient, seeded_templates):
  498. """Verify template can be updated."""
  499. # Get first template
  500. template_id = seeded_templates[0].id
  501. # Update it (route uses PUT, not PATCH)
  502. response = await async_client.put(
  503. f"/api/v1/notification-templates/{template_id}",
  504. json={
  505. "title_template": "Custom Title: {printer}",
  506. "body_template": "Custom body for {filename}",
  507. },
  508. )
  509. assert response.status_code == 200
  510. result = response.json()
  511. assert result["title_template"] == "Custom Title: {printer}"
  512. assert result["body_template"] == "Custom body for {filename}"
  513. @pytest.mark.asyncio
  514. @pytest.mark.integration
  515. async def test_reset_template_to_default(self, async_client: AsyncClient, seeded_templates):
  516. """Verify template can be reset to default."""
  517. template_id = seeded_templates[0].id
  518. response = await async_client.post(f"/api/v1/notification-templates/{template_id}/reset")
  519. assert response.status_code == 200
  520. result = response.json()
  521. assert result["is_default"] is True
  522. class TestHomeAssistantNotificationProvider:
  523. """Integration tests for Home Assistant notification provider."""
  524. @pytest.mark.asyncio
  525. @pytest.mark.integration
  526. async def test_create_homeassistant_provider(self, async_client: AsyncClient):
  527. """Verify homeassistant notification provider can be created with empty config."""
  528. data = {
  529. "name": "HA Notifications",
  530. "provider_type": "homeassistant",
  531. "enabled": True,
  532. "config": {},
  533. "on_print_complete": True,
  534. "on_print_failed": True,
  535. }
  536. response = await async_client.post("/api/v1/notifications/", json=data)
  537. assert response.status_code == 200
  538. result = response.json()
  539. assert result["name"] == "HA Notifications"
  540. assert result["provider_type"] == "homeassistant"
  541. assert result["on_print_complete"] is True
  542. assert result["on_print_failed"] is True
  543. @pytest.mark.asyncio
  544. @pytest.mark.integration
  545. async def test_update_homeassistant_provider(
  546. self, async_client: AsyncClient, notification_provider_factory, db_session
  547. ):
  548. """Verify homeassistant provider can be updated."""
  549. provider = await notification_provider_factory(
  550. name="HA Test",
  551. provider_type="homeassistant",
  552. config="{}",
  553. )
  554. response = await async_client.patch(
  555. f"/api/v1/notifications/{provider.id}",
  556. json={"on_print_start": True, "on_printer_offline": True},
  557. )
  558. assert response.status_code == 200
  559. result = response.json()
  560. assert result["on_print_start"] is True
  561. assert result["on_printer_offline"] is True
  562. @pytest.mark.asyncio
  563. @pytest.mark.integration
  564. async def test_test_homeassistant_config_without_ha_settings(self, async_client: AsyncClient):
  565. """Verify test-config returns error when HA is not configured."""
  566. response = await async_client.post(
  567. "/api/v1/notifications/test-config",
  568. json={"provider_type": "homeassistant", "config": {}},
  569. )
  570. assert response.status_code == 200
  571. result = response.json()
  572. assert result["success"] is False
  573. assert "not configured" in result["message"].lower() or "Home Assistant" in result["message"]