test_not_null_update_schemas.py 4.1 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697
  1. """Update schemas treat an explicit null for a NOT NULL column as "unchanged" (schemas/not_null.py).
  2. The route tests are in tests/integration/test_patch_null_on_required_fields.py.
  3. The guard below keeps every PATCH/PUT body that writes an ORM row on the mixin,
  4. so a new route can't bring the 500 back.
  5. """
  6. from fastapi.routing import APIRoute
  7. from pydantic import Field
  8. from backend.app.schemas.not_null import IgnoresNullForRequiredColumns, _table
  9. # PATCH/PUT bodies that don't update a row of their own, and why.
  10. NOT_A_ROW_UPDATE = {
  11. "AppSettingsUpdate": "key/value settings; rejects null itself",
  12. "SupplierUpdate": "rejects a null name itself with a 422 (its only NOT NULL field)",
  13. "ArchivePurgeSettings": "a setting, every field required",
  14. "TrashSettings": "a setting, every field required",
  15. "DisplaySettingsRequest": "SpoolBuddy device display, every field required",
  16. "ManyfoldConfigUpdate": "stored as settings",
  17. "CostCenterBudgetUpdateRequest": "replaces the budget rows as a whole",
  18. "AmsLabelBody": "upserts a label, every field required",
  19. "LinkTagRequest": "links a tag through its own rules",
  20. "ShoppingListItemStatusUpdate": "one required field",
  21. "ExternalLinkReorder": "a list of ids",
  22. "ExternalLinkBadgeSet": "an app's count for its links, every field required",
  23. "SlicerSettingUpdate": "a Bambu Cloud setting, not a database row",
  24. "SpoolmanInventoryUpdate": "a Spoolman spool, not a database row",
  25. "SpoolWeightUpdate": "a Spoolman spool, not a database row",
  26. "SpoolTagLinkRequest": "a Spoolman spool, not a database row",
  27. "SpoolmanFilamentPatch": "a Spoolman filament, not a database row",
  28. "FilamentProfileDefaultWrite": "replaces a default as a whole; its validator reads a null key field as empty",
  29. }
  30. def test_every_row_update_body_ignores_null_for_required_columns():
  31. from backend.app.main import app
  32. unguarded = []
  33. for route in app.routes:
  34. if not (isinstance(route, APIRoute) and route.methods & {"PATCH", "PUT"} and route.body_field):
  35. continue
  36. schema = route.body_field.field_info.annotation
  37. if not isinstance(schema, type) or not hasattr(schema, "model_fields"):
  38. continue # a list or dict body
  39. if issubclass(schema, IgnoresNullForRequiredColumns) or schema.__name__ in NOT_A_ROW_UPDATE:
  40. continue
  41. unguarded.append(f"{schema.__name__} ({route.path})")
  42. assert unguarded == []
  43. def test_every_declared_table_exists():
  44. from backend.app.main import app
  45. tables = {
  46. route.body_field.field_info.annotation.__orm_table__
  47. for route in app.routes
  48. if isinstance(route, APIRoute)
  49. and route.body_field
  50. and isinstance(route.body_field.field_info.annotation, type)
  51. and issubclass(route.body_field.field_info.annotation, IgnoresNullForRequiredColumns)
  52. }
  53. assert tables
  54. for name in tables:
  55. _table(name) # raises KeyError for a misspelt table
  56. class _PlugUpdate(IgnoresNullForRequiredColumns):
  57. __orm_table__ = "smart_plugs"
  58. name: str | None = None # NOT NULL column
  59. username: str | None = None # nullable column
  60. note_for_client: str | None = None # no column at all
  61. # NOT NULL column; routes write it under the field name, clients may send the alias
  62. off_delay_minutes: int | None = Field(default=None, alias="offDelay")
  63. def test_null_for_a_not_null_column_counts_as_not_sent():
  64. update = _PlugUpdate(name=None, username="x")
  65. assert update.model_dump(exclude_unset=True) == {"username": "x"}
  66. def test_null_for_a_nullable_column_clears_it():
  67. update = _PlugUpdate(username=None)
  68. assert update.model_dump(exclude_unset=True) == {"username": None}
  69. def test_null_for_a_field_with_no_column_is_left_alone():
  70. assert _PlugUpdate(note_for_client=None).note_for_client is None
  71. def test_a_field_sent_under_its_alias_is_checked_by_its_column():
  72. assert _PlugUpdate.model_validate({"offDelay": None}).model_dump(exclude_unset=True) == {}
  73. def test_leaving_a_field_out_is_not_null():
  74. assert _PlugUpdate.model_validate({"username": "x"}).model_dump(exclude_unset=True) == {"username": "x"}