test_notifications_api.py 29 KB

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