test_settings_api.py 42 KB

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