| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163 |
- """PUT /settings/spoolman must not 500 on a JSON boolean.
- The endpoint takes a free-form ``dict`` body, and settings are persisted in a
- VARCHAR column that every reader compares as a string. Sending the natural JSON
- form — ``{"spoolman_enabled": true}`` — used to fail twice over:
- - ``bool.lower()`` raised AttributeError while deciding whether the mode had
- changed, surfacing as an opaque 500;
- - the raw bool was handed to ``upsert_setting``, which SQLite silently coerces
- to 1/0 while asyncpg rejects it — so the stored representation depended on
- the deployment's database.
- The shipped UI sends strings, so this was reachable only through the REST API
- (scripts, Home Assistant ``rest_command``) — which is exactly where a JSON
- boolean is the obvious thing to send.
- These tests cover the normalisers directly. They are pure functions, so the
- matrix stays readable and the endpoint keeps a single code path per field.
- """
- from __future__ import annotations
- import pytest
- from fastapi import HTTPException
- from backend.app.api.routes.settings import (
- normalize_bool_setting,
- normalize_str_setting,
- setting_is_true,
- )
- # ---------------------------------------------------------------------------
- # The reported crash
- # ---------------------------------------------------------------------------
- @pytest.mark.parametrize(("value", "expected"), [(True, "true"), (False, "false")])
- def test_json_booleans_are_accepted_and_canonicalised(value: bool, expected: str):
- """The exact input that used to 500."""
- assert normalize_bool_setting("spoolman_enabled", value) == expected
- @pytest.mark.parametrize(("value", "expected"), [(1, "true"), (0, "false")])
- def test_json_numbers_one_and_zero_are_accepted(value: int, expected: str):
- assert normalize_bool_setting("spoolman_enabled", value) == expected
- # ---------------------------------------------------------------------------
- # String spellings — generous on purpose, this is a documented REST surface
- # ---------------------------------------------------------------------------
- @pytest.mark.parametrize("value", ["true", "TRUE", "True", " true ", "1", "yes", "on", "ON"])
- def test_truthy_spellings(value: str):
- assert normalize_bool_setting("auto_add_unknown_rfid", value) == "true"
- @pytest.mark.parametrize("value", ["false", "FALSE", "False", " false ", "0", "no", "off"])
- def test_falsy_spellings(value: str):
- assert normalize_bool_setting("auto_add_unknown_rfid", value) == "false"
- def test_python_style_capitalised_true_is_normalised_lowercase():
- """The frontend compares with a case-sensitive ``=== 'true'``.
- A client sending "True" previously had it stored verbatim, so the UI
- rendered the setting as OFF while every backend reader (which all use
- ``.lower()``) treated it as ON.
- """
- assert normalize_bool_setting("spoolman_enabled", "True") == "true"
- # ---------------------------------------------------------------------------
- # Empty means "use the default" — deliberately NOT normalised to "false"
- # ---------------------------------------------------------------------------
- @pytest.mark.parametrize("value", ["", " "])
- def test_empty_is_preserved_not_turned_into_false(value: str):
- """get_spoolman_settings reads these with ``or "<default>"``.
- spoolman_report_partial_usage and auto_add_unknown_rfid default to ON, so
- coercing a blank submission to "false" would silently switch them off.
- Whitespace-only collapses to "" so it takes the same path rather than
- being stored as a truthy-but-meaningless " ".
- """
- assert normalize_bool_setting("spoolman_report_partial_usage", value) == ""
- # ---------------------------------------------------------------------------
- # Values with no sensible reading get a 400 naming the field, not a 500
- # ---------------------------------------------------------------------------
- @pytest.mark.parametrize("value", ["banana", "maybe", "2", "-1", None, [], {}, 3.5, 7])
- def test_uninterpretable_values_raise_400_naming_the_field(value: object):
- with pytest.raises(HTTPException) as exc:
- normalize_bool_setting("spoolman_enabled", value)
- assert exc.value.status_code == 400
- assert "spoolman_enabled" in str(exc.value.detail)
- # ---------------------------------------------------------------------------
- # String settings
- # ---------------------------------------------------------------------------
- def test_str_setting_passes_strings_through_untouched():
- assert normalize_str_setting("spoolman_url", "http://192.168.1.5:7912/") == "http://192.168.1.5:7912/"
- def test_str_setting_stringifies_numbers():
- """An unquoted host or port is a plausible client slip, not a hard error."""
- assert normalize_str_setting("spoolman_url", 7912) == "7912"
- def test_str_setting_maps_null_to_empty():
- assert normalize_str_setting("spoolman_url", None) == ""
- @pytest.mark.parametrize("value", [{"a": 1}, ["x"]])
- def test_str_setting_refuses_containers_rather_than_storing_a_repr(value: object):
- with pytest.raises(HTTPException) as exc:
- normalize_str_setting("spoolman_url", value)
- assert exc.value.status_code == 400
- # ---------------------------------------------------------------------------
- # setting_is_true — used for the mode-switch comparison
- # ---------------------------------------------------------------------------
- @pytest.mark.parametrize(
- ("stored", "expected"),
- [
- ("true", True),
- ("True", True),
- ("TRUE", True),
- (" true ", True),
- ("false", False),
- ("", False),
- ("banana", False),
- (None, False), # setting absent from the table
- (True, True), # legacy row: SQLite coerced a raw bool into the column
- (False, False),
- ],
- )
- def test_setting_is_true(stored: object, expected: bool):
- assert setting_is_true(stored) is expected
- @pytest.mark.parametrize("stored", ["1", "on", "yes"])
- def test_setting_is_true_stays_narrower_than_the_write_path(stored: str):
- """Reading must agree with the rest of the codebase, which only accepts "true".
- normalize_bool_setting is generous about what clients may *send*; every
- reader (spoolman_tracking, filament_deficit, inventory, spoolbuddy, labels,
- main) compares ``.lower() == "true"``. Accepting more here would make the
- mode-switch check disagree with them about a legacy row.
- """
- assert setting_is_true(stored) is False
|