test_spool_schemas_rgba.py 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180
  1. """Schema validation tests for the spool rgba field (#1055).
  2. Three guarantees to lock in:
  3. 1. SpoolCreate and SpoolUpdate must reject malformed rgba (short, long, non-hex)
  4. on the write path — this is the "add a check" the reporter asked for.
  5. 2. SpoolResponse must NOT validate rgba on the read path: a single legacy row
  6. with a 7-char rgba (as in #1055) must not 500 the entire inventory list.
  7. 3. Valid 8-char hex must continue to round-trip through all three schemas.
  8. """
  9. import pytest
  10. from pydantic import ValidationError
  11. from backend.app.schemas.spool import SpoolCreate, SpoolUpdate
  12. class TestSpoolCreateRgbaValidation:
  13. """Write-path validation on the create schema."""
  14. def test_accepts_valid_8char_hex(self):
  15. spool = SpoolCreate(material="PLA", rgba="FF00AAFF")
  16. assert spool.rgba == "FF00AAFF"
  17. def test_accepts_lowercase_hex(self):
  18. spool = SpoolCreate(material="PLA", rgba="ff00aaff")
  19. assert spool.rgba == "ff00aaff"
  20. def test_accepts_null_rgba(self):
  21. spool = SpoolCreate(material="PLA", rgba=None)
  22. assert spool.rgba is None
  23. def test_rejects_7char_rgba(self):
  24. """#1055 repro: a 7-char 'FFFFFFF' must not be acceptable on create."""
  25. with pytest.raises(ValidationError, match="rgba"):
  26. SpoolCreate(material="PLA", rgba="FFFFFFF")
  27. def test_rejects_6char_rgba(self):
  28. """Plain RRGGBB without alpha must be rejected — frontend appends FF."""
  29. with pytest.raises(ValidationError, match="rgba"):
  30. SpoolCreate(material="PLA", rgba="FF0000")
  31. def test_rejects_non_hex_char(self):
  32. with pytest.raises(ValidationError, match="rgba"):
  33. SpoolCreate(material="PLA", rgba="FFZZ00FF")
  34. class TestSpoolUpdateRgbaValidation:
  35. """Write-path validation on the update schema — the gap that let #1055 happen.
  36. Before the fix, SpoolUpdate.rgba was a bare `str | None` so a PATCH could
  37. plant a 7-char value straight into the DB. That row then caused a 500 on
  38. the next GET because SpoolResponse enforced the pattern at serialize time.
  39. """
  40. def test_accepts_valid_8char_hex(self):
  41. update = SpoolUpdate(rgba="00FF00FF")
  42. assert update.rgba == "00FF00FF"
  43. def test_accepts_null_rgba(self):
  44. update = SpoolUpdate(rgba=None)
  45. assert update.rgba is None
  46. def test_accepts_missing_rgba(self):
  47. """Partial updates — rgba not present in payload — must still be valid."""
  48. update = SpoolUpdate(material="PETG")
  49. assert update.rgba is None
  50. def test_rejects_7char_rgba(self):
  51. """#1055 repro: PATCH must reject the exact pattern that bricked the reporter."""
  52. with pytest.raises(ValidationError, match="rgba"):
  53. SpoolUpdate(rgba="FFFFFFF")
  54. def test_rejects_9char_rgba(self):
  55. with pytest.raises(ValidationError, match="rgba"):
  56. SpoolUpdate(rgba="FFFFFFFFF")
  57. def test_rejects_non_hex_char(self):
  58. with pytest.raises(ValidationError, match="rgba"):
  59. SpoolUpdate(rgba="FFGG00FF")
  60. class TestSpoolResponseRgbaLeniency:
  61. """Read-path leniency — a legacy bad row must never 500 the list endpoint.
  62. Before the fix, SpoolResponse inherited the pattern from SpoolBase so a
  63. single 7-char rgba in the DB blew up the whole inventory listing. The
  64. response schema now treats rgba as an unconstrained Optional[str] — write
  65. validation is where the pattern belongs; responses must tolerate whatever
  66. is already persisted.
  67. """
  68. # SpoolResponse requires id + timestamps so it's easier to test via a
  69. # minimal dict payload than by constructing a full instance.
  70. @staticmethod
  71. def _make_response_kwargs(**overrides):
  72. from datetime import datetime
  73. base = {
  74. "id": 1,
  75. "material": "PLA",
  76. "created_at": datetime.fromisoformat("2026-01-01T00:00:00"),
  77. "updated_at": datetime.fromisoformat("2026-01-01T00:00:00"),
  78. }
  79. base.update(overrides)
  80. return base
  81. def test_tolerates_7char_rgba_on_serialize(self):
  82. """This is the #1055 bug fixed: malformed legacy rgba must serialize cleanly."""
  83. from backend.app.schemas.spool import SpoolResponse
  84. response = SpoolResponse(**self._make_response_kwargs(rgba="FFFFFFF"))
  85. assert response.rgba == "FFFFFFF"
  86. def test_tolerates_null_rgba(self):
  87. from backend.app.schemas.spool import SpoolResponse
  88. response = SpoolResponse(**self._make_response_kwargs(rgba=None))
  89. assert response.rgba is None
  90. def test_tolerates_non_hex_rgba(self):
  91. """Even completely garbage rgba shouldn't crash the endpoint."""
  92. from backend.app.schemas.spool import SpoolResponse
  93. response = SpoolResponse(**self._make_response_kwargs(rgba="not-hex-at-all"))
  94. assert response.rgba == "not-hex-at-all"
  95. def test_passes_valid_rgba_through(self):
  96. from backend.app.schemas.spool import SpoolResponse
  97. response = SpoolResponse(**self._make_response_kwargs(rgba="FF00AAFF"))
  98. assert response.rgba == "FF00AAFF"
  99. class TestSpoolCategoryAndThreshold:
  100. """#729: per-spool category + low-stock threshold override schema validation."""
  101. def test_create_accepts_category_and_threshold(self):
  102. spool = SpoolCreate(material="PLA", category="Production", low_stock_threshold_pct=50)
  103. assert spool.category == "Production"
  104. assert spool.low_stock_threshold_pct == 50
  105. def test_create_defaults_to_null(self):
  106. """Both new fields are optional and default to None — backward compat."""
  107. spool = SpoolCreate(material="PLA")
  108. assert spool.category is None
  109. assert spool.low_stock_threshold_pct is None
  110. def test_update_accepts_partial_changes(self):
  111. spool = SpoolUpdate(category="Prototype")
  112. assert spool.category == "Prototype"
  113. assert spool.low_stock_threshold_pct is None
  114. def test_update_clears_via_explicit_null(self):
  115. """Sending null on PATCH explicitly resets the override."""
  116. spool = SpoolUpdate(category=None, low_stock_threshold_pct=None)
  117. assert spool.category is None
  118. assert spool.low_stock_threshold_pct is None
  119. def test_threshold_rejects_zero(self):
  120. """0% would mean the spool is never low-stock — disallow as a footgun."""
  121. with pytest.raises(ValidationError, match="low_stock_threshold_pct"):
  122. SpoolCreate(material="PLA", low_stock_threshold_pct=0)
  123. def test_threshold_rejects_100(self):
  124. """100% would mean the spool is always low-stock — disallow."""
  125. with pytest.raises(ValidationError, match="low_stock_threshold_pct"):
  126. SpoolCreate(material="PLA", low_stock_threshold_pct=100)
  127. def test_threshold_rejects_negative(self):
  128. with pytest.raises(ValidationError, match="low_stock_threshold_pct"):
  129. SpoolCreate(material="PLA", low_stock_threshold_pct=-5)
  130. def test_category_rejects_too_long(self):
  131. """50-char cap matches the DB column to prevent silent truncation."""
  132. with pytest.raises(ValidationError, match="category"):
  133. SpoolCreate(material="PLA", category="X" * 51)
  134. def test_category_accepts_max_length(self):
  135. spool = SpoolCreate(material="PLA", category="X" * 50)
  136. assert spool.category == "X" * 50