Files
bambuddy/backend/tests/integration/test_settings_api.py
T
97f6250a0b Add customizable low stock threshold, add low stock filter (#531)
* feat(queue): show spool grams left in filament slot mapping

* Bumped version

* Add SpoolBuddy AMS slot config, external slots, and dashboard redesign

- AMS page: external spool slots (Ext/Ext-L/Ext-R), click-to-configure
  modal on all slots, temperature/humidity threshold-colored indicators,
  nozzle L/R badges for dual-nozzle printers, compact AMS-HT layout
- Dashboard: two-column layout with device status + printers list (left)
  and current spool card (right), state-colored scale/NFC icons, dashed
  border card styling
- Daemon: suppress redundant scale reports (±2g threshold + stability
  state change detection) to prevent weight display bouncing
- TopBar: auto-select online printers only, SpoolBuddy logo

* Fix SpoolBuddy daemon crash when read_tag module is missing

NFCReader.__init__ imported read_tag and instantiated PN5180() outside
the try/except block, so a missing module crashed the entire daemon.
Moved the import inside the existing try/except so the daemon gracefully
skips NFC polling — matching the scale reader's existing behavior.

* Fix SpoolBuddy daemon failing to import hardware drivers

The daemon imports read_tag and scale_diag as bare modules, but they
live in spoolbuddy/scripts/ which isn't on sys.path when systemd runs
the daemon. Added scripts/ to sys.path at startup, resolved relative
to the module file. Also moved the read_tag import inside NFCReader's
try/except (was crashing the daemon instead of skipping gracefully)
and demoted hardware-not-available messages from ERROR to INFO.

* Increase scale moving average window to reduce weight bouncing

5 samples at 100ms (500ms window) wasn't enough to smooth NAU7802 ADC
noise — the averaged value still varied by >2g between 1s report
intervals, and the stability state kept flipping, triggering a report
every cycle. Increased to 20 samples (2s window) so noise is smoothed
before reaching the reporting layer.

* Remove stability flipping as scale report trigger

When ADC noise kept the spread hovering around the 2g stability
threshold, the stable flag toggled every cycle, forcing a report with
a slightly different weight each time. Now only actual weight changes
of >=2g trigger reports. The stable flag is still included in each
report for consumers that need it.

* Fix formatting of option elements in FilamentMapping

* Make low stock threshold editable

* Add new filter for low spools

* Update bug report template to require additional fields

* Added toast for invalid imputs with locales, updated inputb field restrictions

* Minor Spoolbuddy frontend improvements

* Updated test_backend.sh

* Updated Spoolbuddy install script to strip down Raspbian

* Updated Spoolbuddy install script

* Add API key auth support to /auth/me for SpoolBuddy kiosk

When Bambuddy auth is enabled, the SpoolBuddy kiosk gets redirected to
the login page because ProtectedRoute requires a user from GET /auth/me,
which only handled JWT tokens. The kiosk daemon already has an API key
but couldn't use it to satisfy the frontend auth check.

- Backend: /auth/me now accepts API keys (Bearer bb_xxx or X-API-Key)
  and returns a synthetic admin UserResponse with all permissions
- Frontend: AuthContext reads ?token= from URL on first load, stores in
  localStorage, and strips from URL (prevents history/referrer leakage)
- Install script: kiosk URL now includes ?token=${API_KEY}
- Tests: 3 new integration tests (Bearer API key, X-API-Key header,
  invalid key rejection)

* SpoolBuddy touch-friendly UI overhaul for 1024x600 kiosk display

Enlarge all interactive elements across 9 SpoolBuddy components to meet
44px minimum tap targets on the RPi touchscreen. Increase nav icons
(20→24px), labels (10→12px), bar heights, section headers, printer
buttons, spool visualizations, fill bars, and status indicators.
Compact the dashboard stats bar and remove the printers card. Add
fullScreen prop to ConfigureAmsSlotModal with two-column layout
(filament list left, K-profile + color right) to eliminate scrolling.

* Minor changes, CSS fixes

* Refactor usageFilter state to remove 'lowstock' option for clarity

* Move var saving to API, add test coverage

* fix: threshold validation and cleanup

* Change test input from '150' to '0'

---------

Co-authored-by: tridev <c.tripod@gmx.ch>
Co-authored-by: MartinNYHC <mz@v8w.de>
2026-03-03 10:04:13 +01:00

712 lines
27 KiB
Python

"""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)
# ========================================================================
# 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)
# ========================================================================
# 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 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()