test_settings_api.py 36 KB

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