mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
Two new optional fields on Spool: free-text `category` (max 50) and `low_stock_threshold_pct` (1-99). Powers the "differentiate critical spools from prototype spools and alert at different thresholds" use case from #729 without taking on the full multi-tag taxonomy + auto- apply rules + per-tag alert system the ticket originally proposed. Form gains: - Category input with datalist autocomplete sourced from categories already in use, so casing/spelling stays consistent. - Per-spool low-stock threshold input. Empty = global default; the global value renders as the placeholder. Inventory page: - New category filter chip (hidden until at least one spool carries a category — keeps the chip row uncluttered). - Stat-card "Low Stock" count and the "Low Stock" filter both honour the per-spool override. Plus: rename "Delete Tag" button to "Clear RFID Tag" (the original ticket reporter mistook it for a taxonomy-tag delete; the button actually clears the RFID UID/UUID off the spool record). Toast key renamed from `tagDeleted` to `rfidCleared`. i18n: full translations across all 8 locales. Tests: 9 new backend schema tests (defaults, partial-update, range rejection, max-length); 2 new frontend tests (per-spool threshold pulls extra spools into low-stock count, filter chip hidden when no categories exist).
181 lines
7.2 KiB
Python
181 lines
7.2 KiB
Python
"""Schema validation tests for the spool rgba field (#1055).
|
|
|
|
Three guarantees to lock in:
|
|
1. SpoolCreate and SpoolUpdate must reject malformed rgba (short, long, non-hex)
|
|
on the write path — this is the "add a check" the reporter asked for.
|
|
2. SpoolResponse must NOT validate rgba on the read path: a single legacy row
|
|
with a 7-char rgba (as in #1055) must not 500 the entire inventory list.
|
|
3. Valid 8-char hex must continue to round-trip through all three schemas.
|
|
"""
|
|
|
|
import pytest
|
|
from pydantic import ValidationError
|
|
|
|
from backend.app.schemas.spool import SpoolCreate, SpoolUpdate
|
|
|
|
|
|
class TestSpoolCreateRgbaValidation:
|
|
"""Write-path validation on the create schema."""
|
|
|
|
def test_accepts_valid_8char_hex(self):
|
|
spool = SpoolCreate(material="PLA", rgba="FF00AAFF")
|
|
assert spool.rgba == "FF00AAFF"
|
|
|
|
def test_accepts_lowercase_hex(self):
|
|
spool = SpoolCreate(material="PLA", rgba="ff00aaff")
|
|
assert spool.rgba == "ff00aaff"
|
|
|
|
def test_accepts_null_rgba(self):
|
|
spool = SpoolCreate(material="PLA", rgba=None)
|
|
assert spool.rgba is None
|
|
|
|
def test_rejects_7char_rgba(self):
|
|
"""#1055 repro: a 7-char 'FFFFFFF' must not be acceptable on create."""
|
|
with pytest.raises(ValidationError, match="rgba"):
|
|
SpoolCreate(material="PLA", rgba="FFFFFFF")
|
|
|
|
def test_rejects_6char_rgba(self):
|
|
"""Plain RRGGBB without alpha must be rejected — frontend appends FF."""
|
|
with pytest.raises(ValidationError, match="rgba"):
|
|
SpoolCreate(material="PLA", rgba="FF0000")
|
|
|
|
def test_rejects_non_hex_char(self):
|
|
with pytest.raises(ValidationError, match="rgba"):
|
|
SpoolCreate(material="PLA", rgba="FFZZ00FF")
|
|
|
|
|
|
class TestSpoolUpdateRgbaValidation:
|
|
"""Write-path validation on the update schema — the gap that let #1055 happen.
|
|
|
|
Before the fix, SpoolUpdate.rgba was a bare `str | None` so a PATCH could
|
|
plant a 7-char value straight into the DB. That row then caused a 500 on
|
|
the next GET because SpoolResponse enforced the pattern at serialize time.
|
|
"""
|
|
|
|
def test_accepts_valid_8char_hex(self):
|
|
update = SpoolUpdate(rgba="00FF00FF")
|
|
assert update.rgba == "00FF00FF"
|
|
|
|
def test_accepts_null_rgba(self):
|
|
update = SpoolUpdate(rgba=None)
|
|
assert update.rgba is None
|
|
|
|
def test_accepts_missing_rgba(self):
|
|
"""Partial updates — rgba not present in payload — must still be valid."""
|
|
update = SpoolUpdate(material="PETG")
|
|
assert update.rgba is None
|
|
|
|
def test_rejects_7char_rgba(self):
|
|
"""#1055 repro: PATCH must reject the exact pattern that bricked the reporter."""
|
|
with pytest.raises(ValidationError, match="rgba"):
|
|
SpoolUpdate(rgba="FFFFFFF")
|
|
|
|
def test_rejects_9char_rgba(self):
|
|
with pytest.raises(ValidationError, match="rgba"):
|
|
SpoolUpdate(rgba="FFFFFFFFF")
|
|
|
|
def test_rejects_non_hex_char(self):
|
|
with pytest.raises(ValidationError, match="rgba"):
|
|
SpoolUpdate(rgba="FFGG00FF")
|
|
|
|
|
|
class TestSpoolResponseRgbaLeniency:
|
|
"""Read-path leniency — a legacy bad row must never 500 the list endpoint.
|
|
|
|
Before the fix, SpoolResponse inherited the pattern from SpoolBase so a
|
|
single 7-char rgba in the DB blew up the whole inventory listing. The
|
|
response schema now treats rgba as an unconstrained Optional[str] — write
|
|
validation is where the pattern belongs; responses must tolerate whatever
|
|
is already persisted.
|
|
"""
|
|
|
|
# SpoolResponse requires id + timestamps so it's easier to test via a
|
|
# minimal dict payload than by constructing a full instance.
|
|
@staticmethod
|
|
def _make_response_kwargs(**overrides):
|
|
from datetime import datetime
|
|
|
|
base = {
|
|
"id": 1,
|
|
"material": "PLA",
|
|
"created_at": datetime.fromisoformat("2026-01-01T00:00:00"),
|
|
"updated_at": datetime.fromisoformat("2026-01-01T00:00:00"),
|
|
}
|
|
base.update(overrides)
|
|
return base
|
|
|
|
def test_tolerates_7char_rgba_on_serialize(self):
|
|
"""This is the #1055 bug fixed: malformed legacy rgba must serialize cleanly."""
|
|
from backend.app.schemas.spool import SpoolResponse
|
|
|
|
response = SpoolResponse(**self._make_response_kwargs(rgba="FFFFFFF"))
|
|
assert response.rgba == "FFFFFFF"
|
|
|
|
def test_tolerates_null_rgba(self):
|
|
from backend.app.schemas.spool import SpoolResponse
|
|
|
|
response = SpoolResponse(**self._make_response_kwargs(rgba=None))
|
|
assert response.rgba is None
|
|
|
|
def test_tolerates_non_hex_rgba(self):
|
|
"""Even completely garbage rgba shouldn't crash the endpoint."""
|
|
from backend.app.schemas.spool import SpoolResponse
|
|
|
|
response = SpoolResponse(**self._make_response_kwargs(rgba="not-hex-at-all"))
|
|
assert response.rgba == "not-hex-at-all"
|
|
|
|
def test_passes_valid_rgba_through(self):
|
|
from backend.app.schemas.spool import SpoolResponse
|
|
|
|
response = SpoolResponse(**self._make_response_kwargs(rgba="FF00AAFF"))
|
|
assert response.rgba == "FF00AAFF"
|
|
|
|
|
|
class TestSpoolCategoryAndThreshold:
|
|
"""#729: per-spool category + low-stock threshold override schema validation."""
|
|
|
|
def test_create_accepts_category_and_threshold(self):
|
|
spool = SpoolCreate(material="PLA", category="Production", low_stock_threshold_pct=50)
|
|
assert spool.category == "Production"
|
|
assert spool.low_stock_threshold_pct == 50
|
|
|
|
def test_create_defaults_to_null(self):
|
|
"""Both new fields are optional and default to None — backward compat."""
|
|
spool = SpoolCreate(material="PLA")
|
|
assert spool.category is None
|
|
assert spool.low_stock_threshold_pct is None
|
|
|
|
def test_update_accepts_partial_changes(self):
|
|
spool = SpoolUpdate(category="Prototype")
|
|
assert spool.category == "Prototype"
|
|
assert spool.low_stock_threshold_pct is None
|
|
|
|
def test_update_clears_via_explicit_null(self):
|
|
"""Sending null on PATCH explicitly resets the override."""
|
|
spool = SpoolUpdate(category=None, low_stock_threshold_pct=None)
|
|
assert spool.category is None
|
|
assert spool.low_stock_threshold_pct is None
|
|
|
|
def test_threshold_rejects_zero(self):
|
|
"""0% would mean the spool is never low-stock — disallow as a footgun."""
|
|
with pytest.raises(ValidationError, match="low_stock_threshold_pct"):
|
|
SpoolCreate(material="PLA", low_stock_threshold_pct=0)
|
|
|
|
def test_threshold_rejects_100(self):
|
|
"""100% would mean the spool is always low-stock — disallow."""
|
|
with pytest.raises(ValidationError, match="low_stock_threshold_pct"):
|
|
SpoolCreate(material="PLA", low_stock_threshold_pct=100)
|
|
|
|
def test_threshold_rejects_negative(self):
|
|
with pytest.raises(ValidationError, match="low_stock_threshold_pct"):
|
|
SpoolCreate(material="PLA", low_stock_threshold_pct=-5)
|
|
|
|
def test_category_rejects_too_long(self):
|
|
"""50-char cap matches the DB column to prevent silent truncation."""
|
|
with pytest.raises(ValidationError, match="category"):
|
|
SpoolCreate(material="PLA", category="X" * 51)
|
|
|
|
def test_category_accepts_max_length(self):
|
|
spool = SpoolCreate(material="PLA", category="X" * 50)
|
|
assert spool.category == "X" * 50
|