| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057 |
- """Integration tests for Settings API endpoints.
- Tests the full request/response cycle for /api/v1/settings/ endpoints.
- """
- import os
- import pytest
- from httpx import AsyncClient
- class TestSettingsAPI:
- """Integration tests for /api/v1/settings/ endpoints."""
- # ========================================================================
- # Get settings
- # ========================================================================
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_get_settings(self, async_client: AsyncClient):
- """Verify settings can be retrieved."""
- response = await async_client.get("/api/v1/settings/")
- assert response.status_code == 200
- result = response.json()
- # Check for actual settings fields
- assert "auto_archive" in result
- assert "currency" in result
- assert "date_format" in result
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_get_settings_has_defaults(self, async_client: AsyncClient):
- """Verify default settings values are returned."""
- response = await async_client.get("/api/v1/settings/")
- assert response.status_code == 200
- result = response.json()
- # Verify some default values
- assert isinstance(result["auto_archive"], bool)
- assert isinstance(result["currency"], str)
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_unset_temp_alarm_reads_back_as_null(self, async_client: AsyncClient, db_session):
- """#2905: ams_temp_alarm is nullable, and settings storage stringifies
- None to the literal "None".
- Putting it in the plain float-cast list would make float("None") raise
- inside the response builder and take the whole settings response with it
- — every unrelated setting on the page included.
- """
- from backend.app.models.settings import Settings
- db_session.add(Settings(key="ams_temp_alarm", value="None"))
- await db_session.commit()
- response = await async_client.get("/api/v1/settings/")
- assert response.status_code == 200
- assert response.json()["ams_temp_alarm"] is None
- @pytest.mark.asyncio
- @pytest.mark.integration
- @pytest.mark.parametrize(
- ("key", "default"),
- [
- ("ambient_drying_sustained_minutes", 0),
- ("queue_keep_warm_bed_temp", 90),
- ("default_filament_cost", 25.0),
- ],
- )
- async def test_unparseable_number_setting_reads_back_as_the_default(
- self, async_client: AsyncClient, db_session, key, default
- ):
- """A numeric row holding "None" (an old null save) or any other
- unparseable value must fall back to the default, not make int()/float()
- raise inside the response builder and take every setting with it."""
- from backend.app.models.settings import Settings
- db_session.add(Settings(key=key, value="None"))
- await db_session.commit()
- response = await async_client.get("/api/v1/settings/")
- assert response.status_code == 200
- assert response.json()[key] == default
- @pytest.mark.asyncio
- @pytest.mark.integration
- @pytest.mark.parametrize("key", ["ambient_drying_sustained_minutes", "default_filament_cost", "auto_archive"])
- async def test_null_for_a_typed_setting_is_refused_and_not_stored(self, async_client: AsyncClient, key):
- """A null for a boolean or numeric setting would be stored as the literal
- "None". The whole request is refused, so a valid field sent alongside it
- is not half-applied either."""
- before = (await async_client.get("/api/v1/settings/")).json()
- assert before["currency"] != "EUR"
- response = await async_client.put("/api/v1/settings/", json={key: None, "currency": "EUR"})
- assert response.status_code == 422
- assert key in response.json()["detail"]
- after = (await async_client.get("/api/v1/settings/")).json()
- assert after[key] == before[key]
- assert after["currency"] == before["currency"]
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_a_set_temp_alarm_reads_back_as_a_float(self, async_client: AsyncClient, db_session):
- from backend.app.models.settings import Settings
- db_session.add(Settings(key="ams_temp_alarm", value="45"))
- await db_session.commit()
- response = await async_client.get("/api/v1/settings/")
- assert response.status_code == 200
- assert response.json()["ams_temp_alarm"] == 45.0
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_a_malformed_temp_alarm_does_not_break_the_response(self, async_client: AsyncClient, db_session):
- """A hand-edited or half-written value must degrade to "unset" rather
- than making the settings page unreachable."""
- from backend.app.models.settings import Settings
- db_session.add(Settings(key="ams_temp_alarm", value="warm"))
- await db_session.commit()
- response = await async_client.get("/api/v1/settings/")
- assert response.status_code == 200
- assert response.json()["ams_temp_alarm"] is None
- assert "currency" in response.json(), "the rest of the page still renders"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_temp_alarm_survives_a_set_then_clear_round_trip(self, async_client: AsyncClient):
- """The path the settings page actually takes, end to end (#2905).
- The tests above seed rows directly, which pins the read but not the
- convention the whole design rests on: clearing the field sends an
- explicit ``null``, ``update_settings`` stores that as the literal
- string ``"None"``, and the response builder has to turn it back into
- ``None``. A regression anywhere along that chain would leave a cleared
- threshold reading back as the old number, and no test above would fail.
- """
- from sqlalchemy import select
- # Imported here, not at module scope: the async_client fixture patches
- # core.database.async_session onto the test engine, so a name bound at
- # import time would point at the real app database and find nothing.
- from backend.app.core.database import async_session
- from backend.app.models.settings import Settings
- response = await async_client.put("/api/v1/settings/", json={"ams_temp_alarm": 45})
- assert response.status_code == 200
- assert response.json()["ams_temp_alarm"] == 45.0
- assert (await async_client.get("/api/v1/settings/")).json()["ams_temp_alarm"] == 45.0
- response = await async_client.put("/api/v1/settings/", json={"ams_temp_alarm": None})
- assert response.status_code == 200
- assert response.json()["ams_temp_alarm"] is None
- assert (await async_client.get("/api/v1/settings/")).json()["ams_temp_alarm"] is None
- # Pin the stored form too — the fallback in _resolve_temp_alarm_threshold
- # is written against this exact string, so a storage change that silently
- # switched to "" or NULL would break the alarm rather than this test.
- async with async_session() as db:
- row = (await db.execute(select(Settings).where(Settings.key == "ams_temp_alarm"))).scalar_one()
- assert row.value == "None"
- # ========================================================================
- # Update settings
- # ========================================================================
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_update_auto_archive(self, async_client: AsyncClient):
- """Verify auto_archive can be updated."""
- # First get current value
- response = await async_client.get("/api/v1/settings/")
- original = response.json()["auto_archive"]
- # Update to opposite value
- new_value = not original
- response = await async_client.put("/api/v1/settings/", json={"auto_archive": new_value})
- assert response.status_code == 200
- assert response.json()["auto_archive"] == new_value
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_update_currency(self, async_client: AsyncClient):
- """Verify currency can be updated."""
- response = await async_client.put("/api/v1/settings/", json={"currency": "EUR"})
- assert response.status_code == 200
- assert response.json()["currency"] == "EUR"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_update_date_format(self, async_client: AsyncClient):
- """Verify date format can be updated."""
- response = await async_client.put("/api/v1/settings/", json={"date_format": "eu"})
- assert response.status_code == 200
- assert response.json()["date_format"] == "eu"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_update_time_format(self, async_client: AsyncClient):
- """Verify time format can be updated."""
- response = await async_client.put("/api/v1/settings/", json={"time_format": "24h"})
- assert response.status_code == 200
- assert response.json()["time_format"] == "24h"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_update_filament_cost(self, async_client: AsyncClient):
- """Verify default filament cost can be updated."""
- response = await async_client.put("/api/v1/settings/", json={"default_filament_cost": 30.0})
- assert response.status_code == 200
- assert response.json()["default_filament_cost"] == 30.0
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_update_energy_cost(self, async_client: AsyncClient):
- """Verify energy cost can be updated."""
- response = await async_client.put("/api/v1/settings/", json={"energy_cost_per_kwh": 0.20})
- assert response.status_code == 200
- assert response.json()["energy_cost_per_kwh"] == 0.20
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_update_multiple_settings(self, async_client: AsyncClient):
- """Verify multiple settings can be updated at once."""
- response = await async_client.put(
- "/api/v1/settings/",
- json={
- "currency": "GBP",
- "date_format": "iso",
- "time_format": "12h",
- "save_thumbnails": False,
- },
- )
- assert response.status_code == 200
- result = response.json()
- assert result["currency"] == "GBP"
- assert result["date_format"] == "iso"
- assert result["time_format"] == "12h"
- assert result["save_thumbnails"] is False
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_update_spoolman_settings(self, async_client: AsyncClient):
- """Verify Spoolman settings can be updated."""
- response = await async_client.put(
- "/api/v1/settings/",
- json={
- "spoolman_enabled": True,
- "spoolman_url": "http://localhost:7912",
- "spoolman_sync_mode": "manual",
- },
- )
- assert response.status_code == 200
- result = response.json()
- assert result["spoolman_enabled"] is True
- assert result["spoolman_url"] == "http://localhost:7912"
- assert result["spoolman_sync_mode"] == "manual"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_update_ams_thresholds(self, async_client: AsyncClient):
- """Verify AMS threshold settings can be updated."""
- response = await async_client.put(
- "/api/v1/settings/",
- json={
- "ams_humidity_good": 35,
- "ams_humidity_fair": 55,
- "ams_temp_good": 25.0,
- "ams_temp_fair": 32.0,
- },
- )
- assert response.status_code == 200
- result = response.json()
- assert result["ams_humidity_good"] == 35
- assert result["ams_humidity_fair"] == 55
- assert result["ams_temp_good"] == 25.0
- assert result["ams_temp_fair"] == 32.0
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_update_low_stock_threshold(self, async_client: AsyncClient):
- """Verify low stock threshold setting can be updated."""
- # Get default value
- response = await async_client.get("/api/v1/settings/")
- assert response.status_code == 200
- assert response.json()["low_stock_threshold"] == 20.0
- # Update to custom value
- response = await async_client.put("/api/v1/settings/", json={"low_stock_threshold": 15.5})
- assert response.status_code == 200
- result = response.json()
- assert result["low_stock_threshold"] == 15.5
- # Verify persistence
- response = await async_client.get("/api/v1/settings/")
- assert response.status_code == 200
- assert response.json()["low_stock_threshold"] == 15.5
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_update_notification_language(self, async_client: AsyncClient):
- """Verify notification language can be updated."""
- response = await async_client.put("/api/v1/settings/", json={"notification_language": "de"})
- assert response.status_code == 200
- assert response.json()["notification_language"] == "de"
- # ========================================================================
- # Settings persistence tests
- # ========================================================================
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_update_theme_settings(self, async_client: AsyncClient):
- """Verify theme settings can be updated."""
- response = await async_client.put(
- "/api/v1/settings/",
- json={
- "dark_style": "glow",
- "dark_background": "forest",
- "dark_accent": "teal",
- "light_style": "vibrant",
- "light_background": "warm",
- "light_accent": "blue",
- },
- )
- assert response.status_code == 200
- result = response.json()
- assert result["dark_style"] == "glow"
- assert result["dark_background"] == "forest"
- assert result["dark_accent"] == "teal"
- assert result["light_style"] == "vibrant"
- assert result["light_background"] == "warm"
- assert result["light_accent"] == "blue"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_settings_persist_after_update(self, async_client: AsyncClient):
- """CRITICAL: Verify settings changes persist across requests."""
- # Update settings
- await async_client.put("/api/v1/settings/", json={"currency": "JPY", "check_updates": False})
- # Verify persistence in new request
- response = await async_client.get("/api/v1/settings/")
- result = response.json()
- assert result["currency"] == "JPY"
- assert result["check_updates"] is False
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_update_check_printer_firmware(self, async_client: AsyncClient):
- """Verify check_printer_firmware can be updated."""
- # Default should be True
- response = await async_client.get("/api/v1/settings/")
- assert response.json()["check_printer_firmware"] is True
- # Update to False
- response = await async_client.put("/api/v1/settings/", json={"check_printer_firmware": False})
- assert response.status_code == 200
- assert response.json()["check_printer_firmware"] is False
- # Verify persistence
- response = await async_client.get("/api/v1/settings/")
- assert response.json()["check_printer_firmware"] is False
- # Update back to True
- response = await async_client.put("/api/v1/settings/", json={"check_printer_firmware": True})
- assert response.status_code == 200
- assert response.json()["check_printer_firmware"] is True
- # ========================================================================
- # MQTT settings tests
- # ========================================================================
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_update_mqtt_settings(self, async_client: AsyncClient):
- """Verify MQTT settings can be updated."""
- response = await async_client.put(
- "/api/v1/settings/",
- json={
- "mqtt_enabled": True,
- "mqtt_broker": "mqtt.example.com",
- "mqtt_port": 8883,
- "mqtt_username": "testuser",
- "mqtt_password": "testpass",
- "mqtt_topic_prefix": "myprefix",
- "mqtt_use_tls": True,
- },
- )
- assert response.status_code == 200
- result = response.json()
- assert result["mqtt_enabled"] is True
- assert result["mqtt_broker"] == "mqtt.example.com"
- assert result["mqtt_port"] == 8883
- assert result["mqtt_username"] == "testuser"
- assert result["mqtt_password"] == "testpass"
- assert result["mqtt_topic_prefix"] == "myprefix"
- assert result["mqtt_use_tls"] is True
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_mqtt_status_endpoint(self, async_client: AsyncClient):
- """Verify MQTT status endpoint returns expected fields."""
- response = await async_client.get("/api/v1/settings/mqtt/status")
- assert response.status_code == 200
- result = response.json()
- assert "enabled" in result
- assert "connected" in result
- assert "broker" in result
- assert "port" in result
- assert "topic_prefix" in result
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_mqtt_defaults(self, async_client: AsyncClient):
- """Verify MQTT has correct default values."""
- # Reset MQTT settings to defaults
- await async_client.put(
- "/api/v1/settings/",
- json={
- "mqtt_enabled": False,
- "mqtt_broker": "",
- "mqtt_port": 1883,
- "mqtt_username": "",
- "mqtt_password": "",
- "mqtt_topic_prefix": "bambuddy",
- "mqtt_use_tls": False,
- },
- )
- response = await async_client.get("/api/v1/settings/")
- result = response.json()
- assert result["mqtt_enabled"] is False
- assert result["mqtt_port"] == 1883
- assert result["mqtt_topic_prefix"] == "bambuddy"
- assert result["mqtt_use_tls"] is False
- # ========================================================================
- # Camera settings tests
- # ========================================================================
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_update_camera_view_mode(self, async_client: AsyncClient):
- """Verify camera view mode can be updated."""
- response = await async_client.put("/api/v1/settings/", json={"camera_view_mode": "embedded"})
- assert response.status_code == 200
- assert response.json()["camera_view_mode"] == "embedded"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_camera_view_mode_persists(self, async_client: AsyncClient):
- """CRITICAL: Verify camera view mode persists after update."""
- # Update to embedded
- await async_client.put("/api/v1/settings/", json={"camera_view_mode": "embedded"})
- # Verify persistence in new request
- response = await async_client.get("/api/v1/settings/")
- assert response.json()["camera_view_mode"] == "embedded"
- # Update back to window
- await async_client.put("/api/v1/settings/", json={"camera_view_mode": "window"})
- # Verify persistence
- response = await async_client.get("/api/v1/settings/")
- assert response.json()["camera_view_mode"] == "window"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_camera_view_mode_default(self, async_client: AsyncClient):
- """Verify camera view mode has correct default value."""
- # Reset by requesting settings (default should be 'window')
- response = await async_client.get("/api/v1/settings/")
- result = response.json()
- assert "camera_view_mode" in result
- # Default is 'window' as defined in schema
- assert result["camera_view_mode"] in ["window", "embedded"]
- # ========================================================================
- # Per-printer mapping settings tests
- # ========================================================================
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_update_per_printer_mapping_expanded(self, async_client: AsyncClient):
- """Verify per_printer_mapping_expanded can be updated."""
- response = await async_client.put("/api/v1/settings/", json={"per_printer_mapping_expanded": True})
- assert response.status_code == 200
- assert response.json()["per_printer_mapping_expanded"] is True
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_per_printer_mapping_expanded_persists(self, async_client: AsyncClient):
- """CRITICAL: Verify per_printer_mapping_expanded persists after update."""
- # Update to True
- await async_client.put("/api/v1/settings/", json={"per_printer_mapping_expanded": True})
- # Verify persistence in new request
- response = await async_client.get("/api/v1/settings/")
- assert response.json()["per_printer_mapping_expanded"] is True
- # Update back to False
- await async_client.put("/api/v1/settings/", json={"per_printer_mapping_expanded": False})
- # Verify persistence
- response = await async_client.get("/api/v1/settings/")
- assert response.json()["per_printer_mapping_expanded"] is False
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_per_printer_mapping_expanded_default(self, async_client: AsyncClient):
- """Verify per_printer_mapping_expanded has correct default value."""
- response = await async_client.get("/api/v1/settings/")
- result = response.json()
- assert "per_printer_mapping_expanded" in result
- # Default is False as defined in schema
- assert isinstance(result["per_printer_mapping_expanded"], bool)
- # ========================================================================
- # Stagger settings tests
- # ========================================================================
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_stagger_settings_defaults(self, async_client: AsyncClient):
- """Verify stagger settings have correct defaults."""
- response = await async_client.get("/api/v1/settings/")
- result = response.json()
- assert result["stagger_group_size"] == 2
- assert result["stagger_interval_minutes"] == 5
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_update_stagger_settings(self, async_client: AsyncClient):
- """Verify stagger settings can be updated."""
- response = await async_client.put(
- "/api/v1/settings/",
- json={"stagger_group_size": 3, "stagger_interval_minutes": 10},
- )
- assert response.status_code == 200
- result = response.json()
- assert result["stagger_group_size"] == 3
- assert result["stagger_interval_minutes"] == 10
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_stagger_settings_persist(self, async_client: AsyncClient):
- """Verify stagger settings persist after update."""
- await async_client.put(
- "/api/v1/settings/",
- json={"stagger_group_size": 4, "stagger_interval_minutes": 15},
- )
- response = await async_client.get("/api/v1/settings/")
- result = response.json()
- assert result["stagger_group_size"] == 4
- assert result["stagger_interval_minutes"] == 15
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_stagger_settings_validation(self, async_client: AsyncClient):
- """Verify stagger settings reject out-of-range values."""
- response = await async_client.put("/api/v1/settings/", json={"stagger_group_size": 0})
- assert response.status_code == 422
- response = await async_client.put("/api/v1/settings/", json={"stagger_group_size": 51})
- assert response.status_code == 422
- response = await async_client.put("/api/v1/settings/", json={"stagger_interval_minutes": 0})
- assert response.status_code == 422
- response = await async_client.put("/api/v1/settings/", json={"stagger_interval_minutes": 61})
- assert response.status_code == 422
- # ========================================================================
- # Default print options tests
- # ========================================================================
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_default_print_options_defaults(self, async_client: AsyncClient):
- """Verify default print options have correct defaults."""
- response = await async_client.get("/api/v1/settings/")
- result = response.json()
- # bed_levelling / flow_cali are tri-state, defaulting to "auto".
- assert result["default_bed_levelling"] == "auto"
- assert result["default_flow_cali"] == "auto"
- assert result["default_vibration_cali"] is True
- assert result["default_layer_inspect"] is False
- assert result["default_timelapse"] is False
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_update_default_print_options(self, async_client: AsyncClient):
- """Verify default print options can be updated (tri-state + booleans)."""
- response = await async_client.put(
- "/api/v1/settings/",
- json={
- "default_bed_levelling": "off",
- "default_flow_cali": "on",
- "default_vibration_cali": False,
- "default_layer_inspect": True,
- "default_timelapse": True,
- },
- )
- assert response.status_code == 200
- result = response.json()
- assert result["default_bed_levelling"] == "off"
- assert result["default_flow_cali"] == "on"
- assert result["default_vibration_cali"] is False
- assert result["default_layer_inspect"] is True
- assert result["default_timelapse"] is True
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_default_print_options_legacy_bool_coerced(self, async_client: AsyncClient):
- """Old clients sending booleans for the tri-state options still work.
- The TriState validator maps true->"on", false->"off" on input so a
- pre-upgrade frontend never writes an invalid value.
- """
- response = await async_client.put(
- "/api/v1/settings/",
- json={"default_bed_levelling": False, "default_flow_cali": True},
- )
- assert response.status_code == 200
- result = response.json()
- assert result["default_bed_levelling"] == "off"
- assert result["default_flow_cali"] == "on"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_default_print_options_persist(self, async_client: AsyncClient):
- """CRITICAL: Verify default print options persist after update."""
- await async_client.put(
- "/api/v1/settings/",
- json={
- "default_bed_levelling": "on",
- "default_timelapse": True,
- },
- )
- response = await async_client.get("/api/v1/settings/")
- result = response.json()
- assert result["default_bed_levelling"] == "on"
- assert result["default_timelapse"] is True
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_default_print_options_partial_update(self, async_client: AsyncClient):
- """Verify partial updates don't affect other default print options."""
- # Set all to non-default
- await async_client.put(
- "/api/v1/settings/",
- json={
- "default_bed_levelling": "off",
- "default_flow_cali": "on",
- },
- )
- # Update only one
- response = await async_client.put(
- "/api/v1/settings/",
- json={"default_bed_levelling": "auto"},
- )
- assert response.status_code == 200
- result = response.json()
- assert result["default_bed_levelling"] == "auto"
- assert result["default_flow_cali"] == "on" # Should remain from previous update
- # ========================================================================
- # Home Assistant environment variable tests
- # ========================================================================
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_ha_settings_default_no_env_vars(self, async_client: AsyncClient):
- """Verify HA settings work without environment variables (default behavior)."""
- # Ensure no env vars are set
- os.environ.pop("HA_URL", None)
- os.environ.pop("HA_TOKEN", None)
- response = await async_client.get("/api/v1/settings/")
- result = response.json()
- assert response.status_code == 200
- assert "ha_enabled" in result
- assert "ha_url" in result
- assert "ha_token" in result
- assert "ha_url_from_env" in result
- assert "ha_token_from_env" in result
- assert "ha_env_managed" in result
- # Default values without env vars
- assert result["ha_url_from_env"] is False
- assert result["ha_token_from_env"] is False
- assert result["ha_env_managed"] is False
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_ha_settings_with_both_env_vars(self, async_client: AsyncClient):
- """Verify HA settings are overridden when both env vars are set."""
- # Set environment variables
- os.environ["HA_URL"] = "http://supervisor/core"
- os.environ["HA_TOKEN"] = "test-token-12345"
- try:
- response = await async_client.get("/api/v1/settings/")
- result = response.json()
- assert response.status_code == 200
- # Verify env var values are used
- assert result["ha_url"] == "http://supervisor/core"
- assert result["ha_token"] == "test-token-12345"
- # Verify metadata fields
- assert result["ha_url_from_env"] is True
- assert result["ha_token_from_env"] is True
- assert result["ha_env_managed"] is True
- # Verify auto-enable behavior
- assert result["ha_enabled"] is True
- finally:
- # Clean up
- os.environ.pop("HA_URL", None)
- os.environ.pop("HA_TOKEN", None)
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_ha_settings_with_only_url_env_var(self, async_client: AsyncClient):
- """Verify partial configuration when only HA_URL is set."""
- # Set only URL env var
- os.environ["HA_URL"] = "http://supervisor/core"
- os.environ.pop("HA_TOKEN", None)
- try:
- response = await async_client.get("/api/v1/settings/")
- result = response.json()
- assert response.status_code == 200
- # Verify URL is from env, token is from database
- assert result["ha_url"] == "http://supervisor/core"
- assert result["ha_url_from_env"] is True
- assert result["ha_token_from_env"] is False
- assert result["ha_env_managed"] is False
- # No auto-enable with partial config
- assert result["ha_enabled"] is False # Database default
- finally:
- os.environ.pop("HA_URL", None)
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_ha_settings_with_only_token_env_var(self, async_client: AsyncClient):
- """Verify partial configuration when only HA_TOKEN is set."""
- # Set only token env var
- os.environ.pop("HA_URL", None)
- os.environ["HA_TOKEN"] = "test-token-12345"
- try:
- response = await async_client.get("/api/v1/settings/")
- result = response.json()
- assert response.status_code == 200
- # Verify token is from env, URL is from database
- assert result["ha_token"] == "test-token-12345"
- assert result["ha_url_from_env"] is False
- assert result["ha_token_from_env"] is True
- assert result["ha_env_managed"] is False
- # No auto-enable with partial config
- assert result["ha_enabled"] is False # Database default
- finally:
- os.environ.pop("HA_TOKEN", None)
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_ha_settings_env_vars_override_database(self, async_client: AsyncClient):
- """Verify environment variables take precedence over database values."""
- # First, set database values
- await async_client.put(
- "/api/v1/settings/",
- json={
- "ha_enabled": True,
- "ha_url": "http://database-url:8123",
- "ha_token": "database-token",
- },
- )
- # Verify database values are set
- response = await async_client.get("/api/v1/settings/")
- result = response.json()
- assert result["ha_url"] == "http://database-url:8123"
- assert result["ha_token"] == "database-token"
- # Now set environment variables
- os.environ["HA_URL"] = "http://env-url/core"
- os.environ["HA_TOKEN"] = "env-token-xyz"
- try:
- response = await async_client.get("/api/v1/settings/")
- result = response.json()
- # Verify env vars override database
- assert result["ha_url"] == "http://env-url/core"
- assert result["ha_token"] == "env-token-xyz"
- assert result["ha_url_from_env"] is True
- assert result["ha_token_from_env"] is True
- assert result["ha_env_managed"] is True
- assert result["ha_enabled"] is True
- finally:
- os.environ.pop("HA_URL", None)
- os.environ.pop("HA_TOKEN", None)
- # Verify database values are still there after removing env vars
- response = await async_client.get("/api/v1/settings/")
- result = response.json()
- assert result["ha_url"] == "http://database-url:8123"
- assert result["ha_token"] == "database-token"
- assert result["ha_url_from_env"] is False
- assert result["ha_token_from_env"] is False
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_ha_settings_database_updates_accepted_but_ignored(self, async_client: AsyncClient):
- """Verify database updates are accepted but have no effect when env vars are set."""
- # Set environment variables
- os.environ["HA_URL"] = "http://supervisor/core"
- os.environ["HA_TOKEN"] = "env-token"
- try:
- # Attempt to update via API
- response = await async_client.put(
- "/api/v1/settings/",
- json={
- "ha_url": "http://different-url:8123",
- "ha_token": "different-token",
- },
- )
- # Update should succeed
- assert response.status_code == 200
- # But values should still be from env vars
- result = response.json()
- assert result["ha_url"] == "http://supervisor/core"
- assert result["ha_token"] == "env-token"
- assert result["ha_url_from_env"] is True
- assert result["ha_token_from_env"] is True
- finally:
- os.environ.pop("HA_URL", None)
- os.environ.pop("HA_TOKEN", None)
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_ha_settings_empty_env_vars_treated_as_not_set(self, async_client: AsyncClient):
- """Verify empty environment variables are treated as not set."""
- # Set empty env vars
- os.environ["HA_URL"] = ""
- os.environ["HA_TOKEN"] = ""
- try:
- response = await async_client.get("/api/v1/settings/")
- result = response.json()
- # Empty env vars should be treated as not set
- assert result["ha_url_from_env"] is False
- assert result["ha_token_from_env"] is False
- assert result["ha_env_managed"] is False
- finally:
- os.environ.pop("HA_URL", None)
- os.environ.pop("HA_TOKEN", None)
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_ha_settings_can_be_updated_normally_without_env_vars(self, async_client: AsyncClient):
- """Verify HA settings can be updated normally when env vars are not set."""
- # Ensure no env vars
- os.environ.pop("HA_URL", None)
- os.environ.pop("HA_TOKEN", None)
- # Update HA settings
- response = await async_client.put(
- "/api/v1/settings/",
- json={
- "ha_enabled": True,
- "ha_url": "http://192.168.1.100:8123",
- "ha_token": "my-long-lived-token",
- },
- )
- assert response.status_code == 200
- result = response.json()
- assert result["ha_enabled"] is True
- assert result["ha_url"] == "http://192.168.1.100:8123"
- assert result["ha_token"] == "my-long-lived-token"
- assert result["ha_url_from_env"] is False
- assert result["ha_token_from_env"] is False
- assert result["ha_env_managed"] is False
- # Verify persistence
- response = await async_client.get("/api/v1/settings/")
- result = response.json()
- assert result["ha_enabled"] is True
- assert result["ha_url"] == "http://192.168.1.100:8123"
- assert result["ha_token"] == "my-long-lived-token"
- class TestOpenInSlicerOverride:
- """Per #1329, the desktop 'Open in Slicer' target can diverge from the API
- sidecar slicer. The new `open_in_slicer` setting is None by default (frontend
- inherits from `preferred_slicer`); setting it to 'orcaslicer' or 'bambu_studio'
- overrides only the desktop URI handoff, not the in-app SliceModal."""
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_open_in_slicer_default_is_null(self, async_client: AsyncClient):
- response = await async_client.get("/api/v1/settings/")
- assert response.status_code == 200
- # Default null so existing installs behave identically — the frontend
- # then falls back to preferred_slicer.
- assert response.json()["open_in_slicer"] is None
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_open_in_slicer_override_persists(self, async_client: AsyncClient):
- # Set preferred_slicer=bambu_studio (API sidecar) but
- # open_in_slicer=orcaslicer (desktop). Exactly the reporter's case:
- # slice via Bambu Studio sidecar, open files locally in OrcaSlicer.
- response = await async_client.put(
- "/api/v1/settings/",
- json={"preferred_slicer": "bambu_studio", "open_in_slicer": "orcaslicer"},
- )
- assert response.status_code == 200
- body = response.json()
- assert body["preferred_slicer"] == "bambu_studio"
- assert body["open_in_slicer"] == "orcaslicer"
- # Persisted across a fresh GET.
- get_resp = await async_client.get("/api/v1/settings/")
- assert get_resp.json()["preferred_slicer"] == "bambu_studio"
- assert get_resp.json()["open_in_slicer"] == "orcaslicer"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_open_in_slicer_can_be_cleared_to_null(self, async_client: AsyncClient):
- # Reset path: user picks an override, then later goes back to "Same as
- # API slicer". The literal string "None" the PUT path writes for a
- # None value must be normalized back to a real null on GET — otherwise
- # the frontend can't distinguish "explicit override absent" from
- # "explicit override set to a bogus value".
- await async_client.put(
- "/api/v1/settings/",
- json={"open_in_slicer": "orcaslicer"},
- )
- response = await async_client.put(
- "/api/v1/settings/",
- json={"open_in_slicer": None},
- )
- assert response.status_code == 200
- assert response.json()["open_in_slicer"] is None
- # And a fresh GET also sees it as null, not the literal string "None".
- get_resp = await async_client.get("/api/v1/settings/")
- assert get_resp.json()["open_in_slicer"] is None
- class TestSimplifiedBackupRestore:
- """Integration tests for the simplified backup/restore endpoints (ZIP-based).
- Note: Tests that require actual file operations (backup creation) are skipped
- because the test suite uses an in-memory database. These tests focus on
- validation and error handling which don't require file I/O.
- """
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_restore_requires_zip_file(self, async_client: AsyncClient):
- """Verify restore rejects non-ZIP files."""
- files = {"file": ("backup.txt", b"not a zip file", "text/plain")}
- response = await async_client.post("/api/v1/settings/restore", files=files)
- assert response.status_code == 400
- assert "zip" in response.json()["detail"].lower()
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_restore_requires_database_in_zip(self, async_client: AsyncClient):
- """Verify restore rejects ZIP without database file."""
- import io
- import zipfile
- # Create a ZIP without bambuddy.db
- zip_buffer = io.BytesIO()
- with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
- zf.writestr("dummy.txt", "dummy content")
- zip_buffer.seek(0)
- files = {"file": ("backup.zip", zip_buffer.read(), "application/zip")}
- response = await async_client.post("/api/v1/settings/restore", files=files)
- assert response.status_code == 400
- assert "missing bambuddy.db" in response.json()["detail"].lower()
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_restore_invalid_zip(self, async_client: AsyncClient):
- """Verify restore rejects corrupted ZIP files."""
- files = {"file": ("backup.zip", b"not valid zip content", "application/zip")}
- response = await async_client.post("/api/v1/settings/restore", files=files)
- assert response.status_code == 400
- assert "not a valid zip" in response.json()["detail"].lower()
|