test_spool_schemas_rgba.py 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131
  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"