test_settings_api.py 45 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108
  1. """Integration tests for Settings API endpoints.
  2. Tests the full request/response cycle for /api/v1/settings/ endpoints.
  3. """
  4. import os
  5. import pytest
  6. from httpx import AsyncClient
  7. from backend.app.api.routes.settings import get_homeassistant_settings, get_setting
  8. class TestSettingsAPI:
  9. """Integration tests for /api/v1/settings/ endpoints."""
  10. # ========================================================================
  11. # Get settings
  12. # ========================================================================
  13. @pytest.mark.asyncio
  14. @pytest.mark.integration
  15. async def test_get_settings(self, async_client: AsyncClient):
  16. """Verify settings can be retrieved."""
  17. response = await async_client.get("/api/v1/settings/")
  18. assert response.status_code == 200
  19. result = response.json()
  20. # Check for actual settings fields
  21. assert "auto_archive" in result
  22. assert "currency" in result
  23. assert "date_format" in result
  24. @pytest.mark.asyncio
  25. @pytest.mark.integration
  26. async def test_get_settings_has_defaults(self, async_client: AsyncClient):
  27. """Verify default settings values are returned."""
  28. response = await async_client.get("/api/v1/settings/")
  29. assert response.status_code == 200
  30. result = response.json()
  31. # Verify some default values
  32. assert isinstance(result["auto_archive"], bool)
  33. assert isinstance(result["currency"], str)
  34. @pytest.mark.asyncio
  35. @pytest.mark.integration
  36. async def test_unset_temp_alarm_reads_back_as_null(self, async_client: AsyncClient, db_session):
  37. """#2905: ams_temp_alarm is nullable, and settings storage stringifies
  38. None to the literal "None".
  39. Putting it in the plain float-cast list would make float("None") raise
  40. inside the response builder and take the whole settings response with it
  41. — every unrelated setting on the page included.
  42. """
  43. from backend.app.models.settings import Settings
  44. db_session.add(Settings(key="ams_temp_alarm", value="None"))
  45. await db_session.commit()
  46. response = await async_client.get("/api/v1/settings/")
  47. assert response.status_code == 200
  48. assert response.json()["ams_temp_alarm"] is None
  49. @pytest.mark.asyncio
  50. @pytest.mark.integration
  51. @pytest.mark.parametrize(
  52. ("key", "default"),
  53. [
  54. ("ambient_drying_sustained_minutes", 0),
  55. ("queue_keep_warm_bed_temp", 90),
  56. ("default_filament_cost", 25.0),
  57. ],
  58. )
  59. async def test_unparseable_number_setting_reads_back_as_the_default(
  60. self, async_client: AsyncClient, db_session, key, default
  61. ):
  62. """A numeric row holding "None" (an old null save) or any other
  63. unparseable value must fall back to the default, not make int()/float()
  64. raise inside the response builder and take every setting with it."""
  65. from backend.app.models.settings import Settings
  66. db_session.add(Settings(key=key, value="None"))
  67. await db_session.commit()
  68. response = await async_client.get("/api/v1/settings/")
  69. assert response.status_code == 200
  70. assert response.json()[key] == default
  71. @pytest.mark.asyncio
  72. @pytest.mark.integration
  73. @pytest.mark.parametrize(
  74. "key",
  75. [
  76. "ambient_drying_sustained_minutes",
  77. "default_filament_cost",
  78. "auto_archive",
  79. "obico_enabled",
  80. "obico_sensitivity",
  81. "obico_action",
  82. "obico_poll_interval",
  83. "octoeverywhere_enabled",
  84. "octoeverywhere_confidence",
  85. "octoeverywhere_action",
  86. "octoeverywhere_poll_interval",
  87. ],
  88. )
  89. async def test_null_for_a_typed_setting_is_refused_and_not_stored(self, async_client: AsyncClient, key):
  90. """A null for a typed setting would be stored as the literal
  91. "None". The whole request is refused, so a valid field sent alongside it
  92. is not half-applied either."""
  93. before = (await async_client.get("/api/v1/settings/")).json()
  94. assert before["currency"] != "EUR"
  95. response = await async_client.put("/api/v1/settings/", json={key: None, "currency": "EUR"})
  96. assert response.status_code == 422
  97. assert key in response.json()["detail"]
  98. response = await async_client.get("/api/v1/settings/")
  99. assert response.status_code == 200
  100. after = response.json()
  101. assert after[key] == before[key]
  102. assert after["currency"] == before["currency"]
  103. @pytest.mark.asyncio
  104. @pytest.mark.integration
  105. @pytest.mark.parametrize(
  106. "enabled_key, null_key",
  107. [("obico_enabled", "octoeverywhere_enabled"), ("octoeverywhere_enabled", "obico_enabled")],
  108. )
  109. async def test_enabling_provider_does_not_hide_null_other_provider(
  110. self, async_client: AsyncClient, enabled_key, null_key
  111. ):
  112. before = (await async_client.get("/api/v1/settings/")).json()
  113. response = await async_client.put(
  114. "/api/v1/settings/", json={enabled_key: True, null_key: None, "currency": "EUR"}
  115. )
  116. assert response.status_code == 422
  117. assert null_key in response.json()["detail"]
  118. response = await async_client.get("/api/v1/settings/")
  119. assert response.status_code == 200
  120. after = response.json()
  121. for key in (enabled_key, null_key, "currency"):
  122. assert after[key] == before[key]
  123. @pytest.mark.asyncio
  124. @pytest.mark.integration
  125. async def test_a_set_temp_alarm_reads_back_as_a_float(self, async_client: AsyncClient, db_session):
  126. from backend.app.models.settings import Settings
  127. db_session.add(Settings(key="ams_temp_alarm", value="45"))
  128. await db_session.commit()
  129. response = await async_client.get("/api/v1/settings/")
  130. assert response.status_code == 200
  131. assert response.json()["ams_temp_alarm"] == 45.0
  132. @pytest.mark.asyncio
  133. @pytest.mark.integration
  134. async def test_a_malformed_temp_alarm_does_not_break_the_response(self, async_client: AsyncClient, db_session):
  135. """A hand-edited or half-written value must degrade to "unset" rather
  136. than making the settings page unreachable."""
  137. from backend.app.models.settings import Settings
  138. db_session.add(Settings(key="ams_temp_alarm", value="warm"))
  139. await db_session.commit()
  140. response = await async_client.get("/api/v1/settings/")
  141. assert response.status_code == 200
  142. assert response.json()["ams_temp_alarm"] is None
  143. assert "currency" in response.json(), "the rest of the page still renders"
  144. @pytest.mark.asyncio
  145. @pytest.mark.integration
  146. async def test_temp_alarm_survives_a_set_then_clear_round_trip(self, async_client: AsyncClient):
  147. """The path the settings page actually takes, end to end (#2905).
  148. The tests above seed rows directly, which pins the read but not the
  149. convention the whole design rests on: clearing the field sends an
  150. explicit ``null``, ``update_settings`` stores that as the literal
  151. string ``"None"``, and the response builder has to turn it back into
  152. ``None``. A regression anywhere along that chain would leave a cleared
  153. threshold reading back as the old number, and no test above would fail.
  154. """
  155. from sqlalchemy import select
  156. # Imported here, not at module scope: the async_client fixture patches
  157. # core.database.async_session onto the test engine, so a name bound at
  158. # import time would point at the real app database and find nothing.
  159. from backend.app.core.database import async_session
  160. from backend.app.models.settings import Settings
  161. response = await async_client.put("/api/v1/settings/", json={"ams_temp_alarm": 45})
  162. assert response.status_code == 200
  163. assert response.json()["ams_temp_alarm"] == 45.0
  164. assert (await async_client.get("/api/v1/settings/")).json()["ams_temp_alarm"] == 45.0
  165. response = await async_client.put("/api/v1/settings/", json={"ams_temp_alarm": None})
  166. assert response.status_code == 200
  167. assert response.json()["ams_temp_alarm"] is None
  168. assert (await async_client.get("/api/v1/settings/")).json()["ams_temp_alarm"] is None
  169. # Pin the stored form too — the fallback in _resolve_temp_alarm_threshold
  170. # is written against this exact string, so a storage change that silently
  171. # switched to "" or NULL would break the alarm rather than this test.
  172. async with async_session() as db:
  173. row = (await db.execute(select(Settings).where(Settings.key == "ams_temp_alarm"))).scalar_one()
  174. assert row.value == "None"
  175. # ========================================================================
  176. # Update settings
  177. # ========================================================================
  178. @pytest.mark.asyncio
  179. @pytest.mark.integration
  180. async def test_update_auto_archive(self, async_client: AsyncClient):
  181. """Verify auto_archive can be updated."""
  182. # First get current value
  183. response = await async_client.get("/api/v1/settings/")
  184. original = response.json()["auto_archive"]
  185. # Update to opposite value
  186. new_value = not original
  187. response = await async_client.put("/api/v1/settings/", json={"auto_archive": new_value})
  188. assert response.status_code == 200
  189. assert response.json()["auto_archive"] == new_value
  190. @pytest.mark.asyncio
  191. @pytest.mark.integration
  192. async def test_update_currency(self, async_client: AsyncClient):
  193. """Verify currency can be updated."""
  194. response = await async_client.put("/api/v1/settings/", json={"currency": "EUR"})
  195. assert response.status_code == 200
  196. assert response.json()["currency"] == "EUR"
  197. @pytest.mark.asyncio
  198. @pytest.mark.integration
  199. async def test_update_date_format(self, async_client: AsyncClient):
  200. """Verify date format can be updated."""
  201. response = await async_client.put("/api/v1/settings/", json={"date_format": "eu"})
  202. assert response.status_code == 200
  203. assert response.json()["date_format"] == "eu"
  204. @pytest.mark.asyncio
  205. @pytest.mark.integration
  206. async def test_update_time_format(self, async_client: AsyncClient):
  207. """Verify time format can be updated."""
  208. response = await async_client.put("/api/v1/settings/", json={"time_format": "24h"})
  209. assert response.status_code == 200
  210. assert response.json()["time_format"] == "24h"
  211. @pytest.mark.asyncio
  212. @pytest.mark.integration
  213. async def test_update_filament_cost(self, async_client: AsyncClient):
  214. """Verify default filament cost can be updated."""
  215. response = await async_client.put("/api/v1/settings/", json={"default_filament_cost": 30.0})
  216. assert response.status_code == 200
  217. assert response.json()["default_filament_cost"] == 30.0
  218. @pytest.mark.asyncio
  219. @pytest.mark.integration
  220. async def test_update_energy_cost(self, async_client: AsyncClient):
  221. """Verify energy cost can be updated."""
  222. response = await async_client.put("/api/v1/settings/", json={"energy_cost_per_kwh": 0.20})
  223. assert response.status_code == 200
  224. assert response.json()["energy_cost_per_kwh"] == 0.20
  225. @pytest.mark.asyncio
  226. @pytest.mark.integration
  227. async def test_update_multiple_settings(self, async_client: AsyncClient):
  228. """Verify multiple settings can be updated at once."""
  229. response = await async_client.put(
  230. "/api/v1/settings/",
  231. json={
  232. "currency": "GBP",
  233. "date_format": "iso",
  234. "time_format": "12h",
  235. "save_thumbnails": False,
  236. },
  237. )
  238. assert response.status_code == 200
  239. result = response.json()
  240. assert result["currency"] == "GBP"
  241. assert result["date_format"] == "iso"
  242. assert result["time_format"] == "12h"
  243. assert result["save_thumbnails"] is False
  244. @pytest.mark.asyncio
  245. @pytest.mark.integration
  246. async def test_update_spoolman_settings(self, async_client: AsyncClient):
  247. """Verify Spoolman settings can be updated."""
  248. response = await async_client.put(
  249. "/api/v1/settings/",
  250. json={
  251. "spoolman_enabled": True,
  252. "spoolman_url": "http://localhost:7912",
  253. "spoolman_sync_mode": "manual",
  254. },
  255. )
  256. assert response.status_code == 200
  257. result = response.json()
  258. assert result["spoolman_enabled"] is True
  259. assert result["spoolman_url"] == "http://localhost:7912"
  260. assert result["spoolman_sync_mode"] == "manual"
  261. @pytest.mark.asyncio
  262. @pytest.mark.integration
  263. async def test_update_ams_thresholds(self, async_client: AsyncClient):
  264. """Verify AMS threshold settings can be updated."""
  265. response = await async_client.put(
  266. "/api/v1/settings/",
  267. json={
  268. "ams_humidity_good": 35,
  269. "ams_humidity_fair": 55,
  270. "ams_temp_good": 25.0,
  271. "ams_temp_fair": 32.0,
  272. },
  273. )
  274. assert response.status_code == 200
  275. result = response.json()
  276. assert result["ams_humidity_good"] == 35
  277. assert result["ams_humidity_fair"] == 55
  278. assert result["ams_temp_good"] == 25.0
  279. assert result["ams_temp_fair"] == 32.0
  280. @pytest.mark.asyncio
  281. @pytest.mark.integration
  282. async def test_update_low_stock_threshold(self, async_client: AsyncClient):
  283. """Verify low stock threshold setting can be updated."""
  284. # Get default value
  285. response = await async_client.get("/api/v1/settings/")
  286. assert response.status_code == 200
  287. assert response.json()["low_stock_threshold"] == 20.0
  288. # Update to custom value
  289. response = await async_client.put("/api/v1/settings/", json={"low_stock_threshold": 15.5})
  290. assert response.status_code == 200
  291. result = response.json()
  292. assert result["low_stock_threshold"] == 15.5
  293. # Verify persistence
  294. response = await async_client.get("/api/v1/settings/")
  295. assert response.status_code == 200
  296. assert response.json()["low_stock_threshold"] == 15.5
  297. @pytest.mark.asyncio
  298. @pytest.mark.integration
  299. async def test_update_notification_language(self, async_client: AsyncClient):
  300. """Verify notification language can be updated."""
  301. response = await async_client.put("/api/v1/settings/", json={"notification_language": "de"})
  302. assert response.status_code == 200
  303. assert response.json()["notification_language"] == "de"
  304. # ========================================================================
  305. # Settings persistence tests
  306. # ========================================================================
  307. @pytest.mark.asyncio
  308. @pytest.mark.integration
  309. async def test_update_theme_settings(self, async_client: AsyncClient):
  310. """Verify theme settings can be updated."""
  311. response = await async_client.put(
  312. "/api/v1/settings/",
  313. json={
  314. "dark_style": "glow",
  315. "dark_background": "forest",
  316. "dark_accent": "teal",
  317. "light_style": "vibrant",
  318. "light_background": "warm",
  319. "light_accent": "blue",
  320. },
  321. )
  322. assert response.status_code == 200
  323. result = response.json()
  324. assert result["dark_style"] == "glow"
  325. assert result["dark_background"] == "forest"
  326. assert result["dark_accent"] == "teal"
  327. assert result["light_style"] == "vibrant"
  328. assert result["light_background"] == "warm"
  329. assert result["light_accent"] == "blue"
  330. @pytest.mark.asyncio
  331. @pytest.mark.integration
  332. async def test_settings_persist_after_update(self, async_client: AsyncClient):
  333. """CRITICAL: Verify settings changes persist across requests."""
  334. # Update settings
  335. await async_client.put("/api/v1/settings/", json={"currency": "JPY", "check_updates": False})
  336. # Verify persistence in new request
  337. response = await async_client.get("/api/v1/settings/")
  338. result = response.json()
  339. assert result["currency"] == "JPY"
  340. assert result["check_updates"] is False
  341. @pytest.mark.asyncio
  342. @pytest.mark.integration
  343. async def test_update_check_printer_firmware(self, async_client: AsyncClient):
  344. """Verify check_printer_firmware can be updated."""
  345. # Default should be True
  346. response = await async_client.get("/api/v1/settings/")
  347. assert response.json()["check_printer_firmware"] is True
  348. # Update to False
  349. response = await async_client.put("/api/v1/settings/", json={"check_printer_firmware": False})
  350. assert response.status_code == 200
  351. assert response.json()["check_printer_firmware"] is False
  352. # Verify persistence
  353. response = await async_client.get("/api/v1/settings/")
  354. assert response.json()["check_printer_firmware"] is False
  355. # Update back to True
  356. response = await async_client.put("/api/v1/settings/", json={"check_printer_firmware": True})
  357. assert response.status_code == 200
  358. assert response.json()["check_printer_firmware"] is True
  359. # ========================================================================
  360. # MQTT settings tests
  361. # ========================================================================
  362. @pytest.mark.asyncio
  363. @pytest.mark.integration
  364. async def test_update_mqtt_settings(self, async_client: AsyncClient, db_session):
  365. """Verify MQTT settings can be updated."""
  366. response = await async_client.put(
  367. "/api/v1/settings/",
  368. json={
  369. "mqtt_enabled": True,
  370. "mqtt_broker": "mqtt.example.com",
  371. "mqtt_port": 8883,
  372. "mqtt_username": "testuser",
  373. "mqtt_password": "testpass",
  374. "mqtt_topic_prefix": "myprefix",
  375. "mqtt_use_tls": True,
  376. },
  377. )
  378. assert response.status_code == 200
  379. result = response.json()
  380. assert result["mqtt_enabled"] is True
  381. assert result["mqtt_broker"] == "mqtt.example.com"
  382. assert result["mqtt_port"] == 8883
  383. assert result["mqtt_username"] == "testuser"
  384. assert result["mqtt_password"] == "********" # write-only
  385. assert await get_setting(db_session, "mqtt_password") == "testpass"
  386. assert result["mqtt_topic_prefix"] == "myprefix"
  387. assert result["mqtt_use_tls"] is True
  388. @pytest.mark.asyncio
  389. @pytest.mark.integration
  390. async def test_mqtt_status_endpoint(self, async_client: AsyncClient):
  391. """Verify MQTT status endpoint returns expected fields."""
  392. response = await async_client.get("/api/v1/settings/mqtt/status")
  393. assert response.status_code == 200
  394. result = response.json()
  395. assert "enabled" in result
  396. assert "connected" in result
  397. assert "broker" in result
  398. assert "port" in result
  399. assert "topic_prefix" in result
  400. @pytest.mark.asyncio
  401. @pytest.mark.integration
  402. async def test_mqtt_defaults(self, async_client: AsyncClient):
  403. """Verify MQTT has correct default values."""
  404. # Reset MQTT settings to defaults
  405. await async_client.put(
  406. "/api/v1/settings/",
  407. json={
  408. "mqtt_enabled": False,
  409. "mqtt_broker": "",
  410. "mqtt_port": 1883,
  411. "mqtt_username": "",
  412. "mqtt_password": "",
  413. "mqtt_topic_prefix": "bambuddy",
  414. "mqtt_use_tls": False,
  415. },
  416. )
  417. response = await async_client.get("/api/v1/settings/")
  418. result = response.json()
  419. assert result["mqtt_enabled"] is False
  420. assert result["mqtt_port"] == 1883
  421. assert result["mqtt_topic_prefix"] == "bambuddy"
  422. assert result["mqtt_use_tls"] is False
  423. # ========================================================================
  424. # Camera settings tests
  425. # ========================================================================
  426. @pytest.mark.asyncio
  427. @pytest.mark.integration
  428. async def test_update_camera_view_mode(self, async_client: AsyncClient):
  429. """Verify camera view mode can be updated."""
  430. response = await async_client.put("/api/v1/settings/", json={"camera_view_mode": "embedded"})
  431. assert response.status_code == 200
  432. assert response.json()["camera_view_mode"] == "embedded"
  433. @pytest.mark.asyncio
  434. @pytest.mark.integration
  435. async def test_camera_view_mode_persists(self, async_client: AsyncClient):
  436. """CRITICAL: Verify camera view mode persists after update."""
  437. # Update to embedded
  438. await async_client.put("/api/v1/settings/", json={"camera_view_mode": "embedded"})
  439. # Verify persistence in new request
  440. response = await async_client.get("/api/v1/settings/")
  441. assert response.json()["camera_view_mode"] == "embedded"
  442. # Update back to window
  443. await async_client.put("/api/v1/settings/", json={"camera_view_mode": "window"})
  444. # Verify persistence
  445. response = await async_client.get("/api/v1/settings/")
  446. assert response.json()["camera_view_mode"] == "window"
  447. @pytest.mark.asyncio
  448. @pytest.mark.integration
  449. async def test_camera_view_mode_default(self, async_client: AsyncClient):
  450. """Verify camera view mode has correct default value."""
  451. # Reset by requesting settings (default should be 'window')
  452. response = await async_client.get("/api/v1/settings/")
  453. result = response.json()
  454. assert "camera_view_mode" in result
  455. # Default is 'window' as defined in schema
  456. assert result["camera_view_mode"] in ["window", "embedded"]
  457. # ========================================================================
  458. # Per-printer mapping settings tests
  459. # ========================================================================
  460. @pytest.mark.asyncio
  461. @pytest.mark.integration
  462. async def test_update_per_printer_mapping_expanded(self, async_client: AsyncClient):
  463. """Verify per_printer_mapping_expanded can be updated."""
  464. response = await async_client.put("/api/v1/settings/", json={"per_printer_mapping_expanded": True})
  465. assert response.status_code == 200
  466. assert response.json()["per_printer_mapping_expanded"] is True
  467. @pytest.mark.asyncio
  468. @pytest.mark.integration
  469. async def test_per_printer_mapping_expanded_persists(self, async_client: AsyncClient):
  470. """CRITICAL: Verify per_printer_mapping_expanded persists after update."""
  471. # Update to True
  472. await async_client.put("/api/v1/settings/", json={"per_printer_mapping_expanded": True})
  473. # Verify persistence in new request
  474. response = await async_client.get("/api/v1/settings/")
  475. assert response.json()["per_printer_mapping_expanded"] is True
  476. # Update back to False
  477. await async_client.put("/api/v1/settings/", json={"per_printer_mapping_expanded": False})
  478. # Verify persistence
  479. response = await async_client.get("/api/v1/settings/")
  480. assert response.json()["per_printer_mapping_expanded"] is False
  481. @pytest.mark.asyncio
  482. @pytest.mark.integration
  483. async def test_per_printer_mapping_expanded_default(self, async_client: AsyncClient):
  484. """Verify per_printer_mapping_expanded has correct default value."""
  485. response = await async_client.get("/api/v1/settings/")
  486. result = response.json()
  487. assert "per_printer_mapping_expanded" in result
  488. # Default is False as defined in schema
  489. assert isinstance(result["per_printer_mapping_expanded"], bool)
  490. # ========================================================================
  491. # Stagger settings tests
  492. # ========================================================================
  493. @pytest.mark.asyncio
  494. @pytest.mark.integration
  495. async def test_stagger_settings_defaults(self, async_client: AsyncClient):
  496. """Verify stagger settings have correct defaults."""
  497. response = await async_client.get("/api/v1/settings/")
  498. result = response.json()
  499. assert result["stagger_group_size"] == 2
  500. assert result["stagger_interval_minutes"] == 5
  501. @pytest.mark.asyncio
  502. @pytest.mark.integration
  503. async def test_update_stagger_settings(self, async_client: AsyncClient):
  504. """Verify stagger settings can be updated."""
  505. response = await async_client.put(
  506. "/api/v1/settings/",
  507. json={"stagger_group_size": 3, "stagger_interval_minutes": 10},
  508. )
  509. assert response.status_code == 200
  510. result = response.json()
  511. assert result["stagger_group_size"] == 3
  512. assert result["stagger_interval_minutes"] == 10
  513. @pytest.mark.asyncio
  514. @pytest.mark.integration
  515. async def test_stagger_settings_persist(self, async_client: AsyncClient):
  516. """Verify stagger settings persist after update."""
  517. await async_client.put(
  518. "/api/v1/settings/",
  519. json={"stagger_group_size": 4, "stagger_interval_minutes": 15},
  520. )
  521. response = await async_client.get("/api/v1/settings/")
  522. result = response.json()
  523. assert result["stagger_group_size"] == 4
  524. assert result["stagger_interval_minutes"] == 15
  525. @pytest.mark.asyncio
  526. @pytest.mark.integration
  527. async def test_stagger_settings_validation(self, async_client: AsyncClient):
  528. """Verify stagger settings reject out-of-range values."""
  529. response = await async_client.put("/api/v1/settings/", json={"stagger_group_size": 0})
  530. assert response.status_code == 422
  531. response = await async_client.put("/api/v1/settings/", json={"stagger_group_size": 51})
  532. assert response.status_code == 422
  533. response = await async_client.put("/api/v1/settings/", json={"stagger_interval_minutes": 0})
  534. assert response.status_code == 422
  535. response = await async_client.put("/api/v1/settings/", json={"stagger_interval_minutes": 61})
  536. assert response.status_code == 422
  537. # ========================================================================
  538. # Default print options tests
  539. # ========================================================================
  540. @pytest.mark.asyncio
  541. @pytest.mark.integration
  542. async def test_default_print_options_defaults(self, async_client: AsyncClient):
  543. """Verify default print options have correct defaults."""
  544. response = await async_client.get("/api/v1/settings/")
  545. result = response.json()
  546. # bed_levelling / flow_cali are tri-state, defaulting to "auto".
  547. assert result["default_bed_levelling"] == "auto"
  548. assert result["default_flow_cali"] == "auto"
  549. assert result["default_vibration_cali"] is True
  550. assert result["default_layer_inspect"] is False
  551. assert result["default_timelapse"] is False
  552. @pytest.mark.asyncio
  553. @pytest.mark.integration
  554. async def test_update_default_print_options(self, async_client: AsyncClient):
  555. """Verify default print options can be updated (tri-state + booleans)."""
  556. response = await async_client.put(
  557. "/api/v1/settings/",
  558. json={
  559. "default_bed_levelling": "off",
  560. "default_flow_cali": "on",
  561. "default_vibration_cali": False,
  562. "default_layer_inspect": True,
  563. "default_timelapse": True,
  564. },
  565. )
  566. assert response.status_code == 200
  567. result = response.json()
  568. assert result["default_bed_levelling"] == "off"
  569. assert result["default_flow_cali"] == "on"
  570. assert result["default_vibration_cali"] is False
  571. assert result["default_layer_inspect"] is True
  572. assert result["default_timelapse"] is True
  573. @pytest.mark.asyncio
  574. @pytest.mark.integration
  575. async def test_default_print_options_legacy_bool_coerced(self, async_client: AsyncClient):
  576. """Old clients sending booleans for the tri-state options still work.
  577. The TriState validator maps true->"on", false->"off" on input so a
  578. pre-upgrade frontend never writes an invalid value.
  579. """
  580. response = await async_client.put(
  581. "/api/v1/settings/",
  582. json={"default_bed_levelling": False, "default_flow_cali": True},
  583. )
  584. assert response.status_code == 200
  585. result = response.json()
  586. assert result["default_bed_levelling"] == "off"
  587. assert result["default_flow_cali"] == "on"
  588. @pytest.mark.asyncio
  589. @pytest.mark.integration
  590. async def test_default_print_options_persist(self, async_client: AsyncClient):
  591. """CRITICAL: Verify default print options persist after update."""
  592. await async_client.put(
  593. "/api/v1/settings/",
  594. json={
  595. "default_bed_levelling": "on",
  596. "default_timelapse": True,
  597. },
  598. )
  599. response = await async_client.get("/api/v1/settings/")
  600. result = response.json()
  601. assert result["default_bed_levelling"] == "on"
  602. assert result["default_timelapse"] is True
  603. @pytest.mark.asyncio
  604. @pytest.mark.integration
  605. async def test_default_print_options_partial_update(self, async_client: AsyncClient):
  606. """Verify partial updates don't affect other default print options."""
  607. # Set all to non-default
  608. await async_client.put(
  609. "/api/v1/settings/",
  610. json={
  611. "default_bed_levelling": "off",
  612. "default_flow_cali": "on",
  613. },
  614. )
  615. # Update only one
  616. response = await async_client.put(
  617. "/api/v1/settings/",
  618. json={"default_bed_levelling": "auto"},
  619. )
  620. assert response.status_code == 200
  621. result = response.json()
  622. assert result["default_bed_levelling"] == "auto"
  623. assert result["default_flow_cali"] == "on" # Should remain from previous update
  624. # ========================================================================
  625. # Home Assistant environment variable tests
  626. # ========================================================================
  627. @pytest.mark.asyncio
  628. @pytest.mark.integration
  629. async def test_ha_settings_default_no_env_vars(self, async_client: AsyncClient):
  630. """Verify HA settings work without environment variables (default behavior)."""
  631. # Ensure no env vars are set
  632. os.environ.pop("HA_URL", None)
  633. os.environ.pop("HA_TOKEN", None)
  634. response = await async_client.get("/api/v1/settings/")
  635. result = response.json()
  636. assert response.status_code == 200
  637. assert "ha_enabled" in result
  638. assert "ha_url" in result
  639. assert "ha_token" in result
  640. assert "ha_url_from_env" in result
  641. assert "ha_token_from_env" in result
  642. assert "ha_env_managed" in result
  643. # Default values without env vars
  644. assert result["ha_url_from_env"] is False
  645. assert result["ha_token_from_env"] is False
  646. assert result["ha_env_managed"] is False
  647. @pytest.mark.asyncio
  648. @pytest.mark.integration
  649. async def test_ha_settings_with_both_env_vars(self, async_client: AsyncClient, db_session):
  650. """Verify HA settings are overridden when both env vars are set."""
  651. # Set environment variables
  652. os.environ["HA_URL"] = "http://supervisor/core"
  653. os.environ["HA_TOKEN"] = "test-token-12345"
  654. try:
  655. response = await async_client.get("/api/v1/settings/")
  656. result = response.json()
  657. assert response.status_code == 200
  658. # Verify env var values are used
  659. assert result["ha_url"] == "http://supervisor/core"
  660. assert result["ha_token"] == "********" # write-only
  661. assert (await get_homeassistant_settings(db_session))["ha_token"] == "test-token-12345"
  662. # Verify metadata fields
  663. assert result["ha_url_from_env"] is True
  664. assert result["ha_token_from_env"] is True
  665. assert result["ha_env_managed"] is True
  666. # Verify auto-enable behavior
  667. assert result["ha_enabled"] is True
  668. finally:
  669. # Clean up
  670. os.environ.pop("HA_URL", None)
  671. os.environ.pop("HA_TOKEN", None)
  672. @pytest.mark.asyncio
  673. @pytest.mark.integration
  674. async def test_ha_settings_with_only_url_env_var(self, async_client: AsyncClient):
  675. """Verify partial configuration when only HA_URL is set."""
  676. # Set only URL env var
  677. os.environ["HA_URL"] = "http://supervisor/core"
  678. os.environ.pop("HA_TOKEN", None)
  679. try:
  680. response = await async_client.get("/api/v1/settings/")
  681. result = response.json()
  682. assert response.status_code == 200
  683. # Verify URL is from env, token is from database
  684. assert result["ha_url"] == "http://supervisor/core"
  685. assert result["ha_url_from_env"] is True
  686. assert result["ha_token_from_env"] is False
  687. assert result["ha_env_managed"] is False
  688. # No auto-enable with partial config
  689. assert result["ha_enabled"] is False # Database default
  690. finally:
  691. os.environ.pop("HA_URL", None)
  692. @pytest.mark.asyncio
  693. @pytest.mark.integration
  694. async def test_ha_settings_with_only_token_env_var(self, async_client: AsyncClient, db_session):
  695. """Verify partial configuration when only HA_TOKEN is set."""
  696. # Set only token env var
  697. os.environ.pop("HA_URL", None)
  698. os.environ["HA_TOKEN"] = "test-token-12345"
  699. try:
  700. response = await async_client.get("/api/v1/settings/")
  701. result = response.json()
  702. assert response.status_code == 200
  703. # Verify token is from env, URL is from database
  704. assert result["ha_token"] == "********" # write-only
  705. assert (await get_homeassistant_settings(db_session))["ha_token"] == "test-token-12345"
  706. assert result["ha_url_from_env"] is False
  707. assert result["ha_token_from_env"] is True
  708. assert result["ha_env_managed"] is False
  709. # No auto-enable with partial config
  710. assert result["ha_enabled"] is False # Database default
  711. finally:
  712. os.environ.pop("HA_TOKEN", None)
  713. @pytest.mark.asyncio
  714. @pytest.mark.integration
  715. async def test_ha_settings_env_vars_override_database(self, async_client: AsyncClient, db_session):
  716. """Verify environment variables take precedence over database values."""
  717. # First, set database values
  718. await async_client.put(
  719. "/api/v1/settings/",
  720. json={
  721. "ha_enabled": True,
  722. "ha_url": "http://database-url:8123",
  723. "ha_token": "database-token",
  724. },
  725. )
  726. # Verify database values are set
  727. response = await async_client.get("/api/v1/settings/")
  728. result = response.json()
  729. assert result["ha_url"] == "http://database-url:8123"
  730. assert result["ha_token"] == "********" # write-only
  731. assert (await get_homeassistant_settings(db_session))["ha_token"] == "database-token"
  732. # Now set environment variables
  733. os.environ["HA_URL"] = "http://env-url/core"
  734. os.environ["HA_TOKEN"] = "env-token-xyz"
  735. try:
  736. response = await async_client.get("/api/v1/settings/")
  737. result = response.json()
  738. # Verify env vars override database
  739. assert result["ha_url"] == "http://env-url/core"
  740. assert result["ha_token"] == "********" # write-only
  741. assert (await get_homeassistant_settings(db_session))["ha_token"] == "env-token-xyz"
  742. assert result["ha_url_from_env"] is True
  743. assert result["ha_token_from_env"] is True
  744. assert result["ha_env_managed"] is True
  745. assert result["ha_enabled"] is True
  746. finally:
  747. os.environ.pop("HA_URL", None)
  748. os.environ.pop("HA_TOKEN", None)
  749. # Verify database values are still there after removing env vars
  750. response = await async_client.get("/api/v1/settings/")
  751. result = response.json()
  752. assert result["ha_url"] == "http://database-url:8123"
  753. assert result["ha_token"] == "********" # write-only
  754. assert (await get_homeassistant_settings(db_session))["ha_token"] == "database-token"
  755. assert result["ha_url_from_env"] is False
  756. assert result["ha_token_from_env"] is False
  757. @pytest.mark.asyncio
  758. @pytest.mark.integration
  759. async def test_ha_settings_database_updates_accepted_but_ignored(self, async_client: AsyncClient, db_session):
  760. """Verify database updates are accepted but have no effect when env vars are set."""
  761. # Set environment variables
  762. os.environ["HA_URL"] = "http://supervisor/core"
  763. os.environ["HA_TOKEN"] = "env-token"
  764. try:
  765. # Attempt to update via API
  766. response = await async_client.put(
  767. "/api/v1/settings/",
  768. json={
  769. "ha_url": "http://different-url:8123",
  770. "ha_token": "different-token",
  771. },
  772. )
  773. # Update should succeed
  774. assert response.status_code == 200
  775. # But values should still be from env vars
  776. result = response.json()
  777. assert result["ha_url"] == "http://supervisor/core"
  778. assert result["ha_token"] == "********" # write-only
  779. assert (await get_homeassistant_settings(db_session))["ha_token"] == "env-token"
  780. assert result["ha_url_from_env"] is True
  781. assert result["ha_token_from_env"] is True
  782. finally:
  783. os.environ.pop("HA_URL", None)
  784. os.environ.pop("HA_TOKEN", None)
  785. @pytest.mark.asyncio
  786. @pytest.mark.integration
  787. async def test_ha_settings_empty_env_vars_treated_as_not_set(self, async_client: AsyncClient):
  788. """Verify empty environment variables are treated as not set."""
  789. # Set empty env vars
  790. os.environ["HA_URL"] = ""
  791. os.environ["HA_TOKEN"] = ""
  792. try:
  793. response = await async_client.get("/api/v1/settings/")
  794. result = response.json()
  795. # Empty env vars should be treated as not set
  796. assert result["ha_url_from_env"] is False
  797. assert result["ha_token_from_env"] is False
  798. assert result["ha_env_managed"] is False
  799. finally:
  800. os.environ.pop("HA_URL", None)
  801. os.environ.pop("HA_TOKEN", None)
  802. @pytest.mark.asyncio
  803. @pytest.mark.integration
  804. async def test_ha_settings_can_be_updated_normally_without_env_vars(self, async_client: AsyncClient, db_session):
  805. """Verify HA settings can be updated normally when env vars are not set."""
  806. # Ensure no env vars
  807. os.environ.pop("HA_URL", None)
  808. os.environ.pop("HA_TOKEN", None)
  809. # Update HA settings
  810. response = await async_client.put(
  811. "/api/v1/settings/",
  812. json={
  813. "ha_enabled": True,
  814. "ha_url": "http://192.168.1.100:8123",
  815. "ha_token": "my-long-lived-token",
  816. },
  817. )
  818. assert response.status_code == 200
  819. result = response.json()
  820. assert result["ha_enabled"] is True
  821. assert result["ha_url"] == "http://192.168.1.100:8123"
  822. assert result["ha_token"] == "********" # write-only
  823. assert (await get_homeassistant_settings(db_session))["ha_token"] == "my-long-lived-token"
  824. assert result["ha_url_from_env"] is False
  825. assert result["ha_token_from_env"] is False
  826. assert result["ha_env_managed"] is False
  827. # Verify persistence
  828. response = await async_client.get("/api/v1/settings/")
  829. result = response.json()
  830. assert result["ha_enabled"] is True
  831. assert result["ha_url"] == "http://192.168.1.100:8123"
  832. assert result["ha_token"] == "********" # write-only
  833. assert (await get_homeassistant_settings(db_session))["ha_token"] == "my-long-lived-token"
  834. class TestOpenInSlicerOverride:
  835. """Per #1329, the desktop 'Open in Slicer' target can diverge from the API
  836. sidecar slicer. The new `open_in_slicer` setting is None by default (frontend
  837. inherits from `preferred_slicer`); setting it to 'orcaslicer' or 'bambu_studio'
  838. overrides only the desktop URI handoff, not the in-app SliceModal."""
  839. @pytest.mark.asyncio
  840. @pytest.mark.integration
  841. async def test_open_in_slicer_default_is_null(self, async_client: AsyncClient):
  842. response = await async_client.get("/api/v1/settings/")
  843. assert response.status_code == 200
  844. # Default null so existing installs behave identically — the frontend
  845. # then falls back to preferred_slicer.
  846. assert response.json()["open_in_slicer"] is None
  847. @pytest.mark.asyncio
  848. @pytest.mark.integration
  849. async def test_open_in_slicer_override_persists(self, async_client: AsyncClient):
  850. # Set preferred_slicer=bambu_studio (API sidecar) but
  851. # open_in_slicer=orcaslicer (desktop). Exactly the reporter's case:
  852. # slice via Bambu Studio sidecar, open files locally in OrcaSlicer.
  853. response = await async_client.put(
  854. "/api/v1/settings/",
  855. json={"preferred_slicer": "bambu_studio", "open_in_slicer": "orcaslicer"},
  856. )
  857. assert response.status_code == 200
  858. body = response.json()
  859. assert body["preferred_slicer"] == "bambu_studio"
  860. assert body["open_in_slicer"] == "orcaslicer"
  861. # Persisted across a fresh GET.
  862. get_resp = await async_client.get("/api/v1/settings/")
  863. assert get_resp.json()["preferred_slicer"] == "bambu_studio"
  864. assert get_resp.json()["open_in_slicer"] == "orcaslicer"
  865. @pytest.mark.asyncio
  866. @pytest.mark.integration
  867. async def test_open_in_slicer_can_be_cleared_to_null(self, async_client: AsyncClient):
  868. # Reset path: user picks an override, then later goes back to "Same as
  869. # API slicer". The literal string "None" the PUT path writes for a
  870. # None value must be normalized back to a real null on GET — otherwise
  871. # the frontend can't distinguish "explicit override absent" from
  872. # "explicit override set to a bogus value".
  873. await async_client.put(
  874. "/api/v1/settings/",
  875. json={"open_in_slicer": "orcaslicer"},
  876. )
  877. response = await async_client.put(
  878. "/api/v1/settings/",
  879. json={"open_in_slicer": None},
  880. )
  881. assert response.status_code == 200
  882. assert response.json()["open_in_slicer"] is None
  883. # And a fresh GET also sees it as null, not the literal string "None".
  884. get_resp = await async_client.get("/api/v1/settings/")
  885. assert get_resp.json()["open_in_slicer"] is None
  886. class TestSimplifiedBackupRestore:
  887. """Integration tests for the simplified backup/restore endpoints (ZIP-based).
  888. Note: Tests that require actual file operations (backup creation) are skipped
  889. because the test suite uses an in-memory database. These tests focus on
  890. validation and error handling which don't require file I/O.
  891. """
  892. @pytest.mark.asyncio
  893. @pytest.mark.integration
  894. async def test_restore_requires_zip_file(self, async_client: AsyncClient):
  895. """Verify restore rejects non-ZIP files."""
  896. files = {"file": ("backup.txt", b"not a zip file", "text/plain")}
  897. response = await async_client.post("/api/v1/settings/restore", files=files)
  898. assert response.status_code == 400
  899. assert "zip" in response.json()["detail"].lower()
  900. @pytest.mark.asyncio
  901. @pytest.mark.integration
  902. async def test_restore_requires_database_in_zip(self, async_client: AsyncClient):
  903. """Verify restore rejects ZIP without database file."""
  904. import io
  905. import zipfile
  906. # Create a ZIP without bambuddy.db
  907. zip_buffer = io.BytesIO()
  908. with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
  909. zf.writestr("dummy.txt", "dummy content")
  910. zip_buffer.seek(0)
  911. files = {"file": ("backup.zip", zip_buffer.read(), "application/zip")}
  912. response = await async_client.post("/api/v1/settings/restore", files=files)
  913. assert response.status_code == 400
  914. assert "missing bambuddy.db" in response.json()["detail"].lower()
  915. @pytest.mark.asyncio
  916. @pytest.mark.integration
  917. async def test_restore_invalid_zip(self, async_client: AsyncClient):
  918. """Verify restore rejects corrupted ZIP files."""
  919. files = {"file": ("backup.zip", b"not valid zip content", "application/zip")}
  920. response = await async_client.post("/api/v1/settings/restore", files=files)
  921. assert response.status_code == 400
  922. assert "not a valid zip" in response.json()["detail"].lower()