test_spoolman_settings_value_coercion.py 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163
  1. """PUT /settings/spoolman must not 500 on a JSON boolean.
  2. The endpoint takes a free-form ``dict`` body, and settings are persisted in a
  3. VARCHAR column that every reader compares as a string. Sending the natural JSON
  4. form — ``{"spoolman_enabled": true}`` — used to fail twice over:
  5. - ``bool.lower()`` raised AttributeError while deciding whether the mode had
  6. changed, surfacing as an opaque 500;
  7. - the raw bool was handed to ``upsert_setting``, which SQLite silently coerces
  8. to 1/0 while asyncpg rejects it — so the stored representation depended on
  9. the deployment's database.
  10. The shipped UI sends strings, so this was reachable only through the REST API
  11. (scripts, Home Assistant ``rest_command``) — which is exactly where a JSON
  12. boolean is the obvious thing to send.
  13. These tests cover the normalisers directly. They are pure functions, so the
  14. matrix stays readable and the endpoint keeps a single code path per field.
  15. """
  16. from __future__ import annotations
  17. import pytest
  18. from fastapi import HTTPException
  19. from backend.app.api.routes.settings import (
  20. normalize_bool_setting,
  21. normalize_str_setting,
  22. setting_is_true,
  23. )
  24. # ---------------------------------------------------------------------------
  25. # The reported crash
  26. # ---------------------------------------------------------------------------
  27. @pytest.mark.parametrize(("value", "expected"), [(True, "true"), (False, "false")])
  28. def test_json_booleans_are_accepted_and_canonicalised(value: bool, expected: str):
  29. """The exact input that used to 500."""
  30. assert normalize_bool_setting("spoolman_enabled", value) == expected
  31. @pytest.mark.parametrize(("value", "expected"), [(1, "true"), (0, "false")])
  32. def test_json_numbers_one_and_zero_are_accepted(value: int, expected: str):
  33. assert normalize_bool_setting("spoolman_enabled", value) == expected
  34. # ---------------------------------------------------------------------------
  35. # String spellings — generous on purpose, this is a documented REST surface
  36. # ---------------------------------------------------------------------------
  37. @pytest.mark.parametrize("value", ["true", "TRUE", "True", " true ", "1", "yes", "on", "ON"])
  38. def test_truthy_spellings(value: str):
  39. assert normalize_bool_setting("auto_add_unknown_rfid", value) == "true"
  40. @pytest.mark.parametrize("value", ["false", "FALSE", "False", " false ", "0", "no", "off"])
  41. def test_falsy_spellings(value: str):
  42. assert normalize_bool_setting("auto_add_unknown_rfid", value) == "false"
  43. def test_python_style_capitalised_true_is_normalised_lowercase():
  44. """The frontend compares with a case-sensitive ``=== 'true'``.
  45. A client sending "True" previously had it stored verbatim, so the UI
  46. rendered the setting as OFF while every backend reader (which all use
  47. ``.lower()``) treated it as ON.
  48. """
  49. assert normalize_bool_setting("spoolman_enabled", "True") == "true"
  50. # ---------------------------------------------------------------------------
  51. # Empty means "use the default" — deliberately NOT normalised to "false"
  52. # ---------------------------------------------------------------------------
  53. @pytest.mark.parametrize("value", ["", " "])
  54. def test_empty_is_preserved_not_turned_into_false(value: str):
  55. """get_spoolman_settings reads these with ``or "<default>"``.
  56. spoolman_report_partial_usage and auto_add_unknown_rfid default to ON, so
  57. coercing a blank submission to "false" would silently switch them off.
  58. Whitespace-only collapses to "" so it takes the same path rather than
  59. being stored as a truthy-but-meaningless " ".
  60. """
  61. assert normalize_bool_setting("spoolman_report_partial_usage", value) == ""
  62. # ---------------------------------------------------------------------------
  63. # Values with no sensible reading get a 400 naming the field, not a 500
  64. # ---------------------------------------------------------------------------
  65. @pytest.mark.parametrize("value", ["banana", "maybe", "2", "-1", None, [], {}, 3.5, 7])
  66. def test_uninterpretable_values_raise_400_naming_the_field(value: object):
  67. with pytest.raises(HTTPException) as exc:
  68. normalize_bool_setting("spoolman_enabled", value)
  69. assert exc.value.status_code == 400
  70. assert "spoolman_enabled" in str(exc.value.detail)
  71. # ---------------------------------------------------------------------------
  72. # String settings
  73. # ---------------------------------------------------------------------------
  74. def test_str_setting_passes_strings_through_untouched():
  75. assert normalize_str_setting("spoolman_url", "http://192.168.1.5:7912/") == "http://192.168.1.5:7912/"
  76. def test_str_setting_stringifies_numbers():
  77. """An unquoted host or port is a plausible client slip, not a hard error."""
  78. assert normalize_str_setting("spoolman_url", 7912) == "7912"
  79. def test_str_setting_maps_null_to_empty():
  80. assert normalize_str_setting("spoolman_url", None) == ""
  81. @pytest.mark.parametrize("value", [{"a": 1}, ["x"]])
  82. def test_str_setting_refuses_containers_rather_than_storing_a_repr(value: object):
  83. with pytest.raises(HTTPException) as exc:
  84. normalize_str_setting("spoolman_url", value)
  85. assert exc.value.status_code == 400
  86. # ---------------------------------------------------------------------------
  87. # setting_is_true — used for the mode-switch comparison
  88. # ---------------------------------------------------------------------------
  89. @pytest.mark.parametrize(
  90. ("stored", "expected"),
  91. [
  92. ("true", True),
  93. ("True", True),
  94. ("TRUE", True),
  95. (" true ", True),
  96. ("false", False),
  97. ("", False),
  98. ("banana", False),
  99. (None, False), # setting absent from the table
  100. (True, True), # legacy row: SQLite coerced a raw bool into the column
  101. (False, False),
  102. ],
  103. )
  104. def test_setting_is_true(stored: object, expected: bool):
  105. assert setting_is_true(stored) is expected
  106. @pytest.mark.parametrize("stored", ["1", "on", "yes"])
  107. def test_setting_is_true_stays_narrower_than_the_write_path(stored: str):
  108. """Reading must agree with the rest of the codebase, which only accepts "true".
  109. normalize_bool_setting is generous about what clients may *send*; every
  110. reader (spoolman_tracking, filament_deficit, inventory, spoolbuddy, labels,
  111. main) compares ``.lower() == "true"``. Accepting more here would make the
  112. mode-switch check disagree with them about a legacy row.
  113. """
  114. assert setting_is_true(stored) is False