test_settings_api.py 45 KB

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