test_not_null_update_schemas.py 4.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596
  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. }
  29. def test_every_row_update_body_ignores_null_for_required_columns():
  30. from backend.app.main import app
  31. unguarded = []
  32. for route in app.routes:
  33. if not (isinstance(route, APIRoute) and route.methods & {"PATCH", "PUT"} and route.body_field):
  34. continue
  35. schema = route.body_field.field_info.annotation
  36. if not isinstance(schema, type) or not hasattr(schema, "model_fields"):
  37. continue # a list or dict body
  38. if issubclass(schema, IgnoresNullForRequiredColumns) or schema.__name__ in NOT_A_ROW_UPDATE:
  39. continue
  40. unguarded.append(f"{schema.__name__} ({route.path})")
  41. assert unguarded == []
  42. def test_every_declared_table_exists():
  43. from backend.app.main import app
  44. tables = {
  45. route.body_field.field_info.annotation.__orm_table__
  46. for route in app.routes
  47. if isinstance(route, APIRoute)
  48. and route.body_field
  49. and isinstance(route.body_field.field_info.annotation, type)
  50. and issubclass(route.body_field.field_info.annotation, IgnoresNullForRequiredColumns)
  51. }
  52. assert tables
  53. for name in tables:
  54. _table(name) # raises KeyError for a misspelt table
  55. class _PlugUpdate(IgnoresNullForRequiredColumns):
  56. __orm_table__ = "smart_plugs"
  57. name: str | None = None # NOT NULL column
  58. username: str | None = None # nullable column
  59. note_for_client: str | None = None # no column at all
  60. # NOT NULL column; routes write it under the field name, clients may send the alias
  61. off_delay_minutes: int | None = Field(default=None, alias="offDelay")
  62. def test_null_for_a_not_null_column_counts_as_not_sent():
  63. update = _PlugUpdate(name=None, username="x")
  64. assert update.model_dump(exclude_unset=True) == {"username": "x"}
  65. def test_null_for_a_nullable_column_clears_it():
  66. update = _PlugUpdate(username=None)
  67. assert update.model_dump(exclude_unset=True) == {"username": None}
  68. def test_null_for_a_field_with_no_column_is_left_alone():
  69. assert _PlugUpdate(note_for_client=None).note_for_client is None
  70. def test_a_field_sent_under_its_alias_is_checked_by_its_column():
  71. assert _PlugUpdate.model_validate({"offDelay": None}).model_dump(exclude_unset=True) == {}
  72. def test_leaving_a_field_out_is_not_null():
  73. assert _PlugUpdate.model_validate({"username": "x"}).model_dump(exclude_unset=True) == {"username": "x"}