test_notifications_api.py 28 KB

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