test_settings_api.py 40 KB

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