test_settings_api.py 43 KB

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