test_not_null_update_schemas.py 4.2 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798
  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. "LabelTemplateIn": "replaces a saved label template as a whole, every field required",
  20. "LinkTagRequest": "links a tag through its own rules",
  21. "ShoppingListItemStatusUpdate": "one required field",
  22. "ExternalLinkReorder": "a list of ids",
  23. "ExternalLinkBadgeSet": "an app's count for its links, every field required",
  24. "SlicerSettingUpdate": "a Bambu Cloud setting, not a database row",
  25. "SpoolmanInventoryUpdate": "a Spoolman spool, not a database row",
  26. "SpoolWeightUpdate": "a Spoolman spool, not a database row",
  27. "SpoolTagLinkRequest": "a Spoolman spool, not a database row",
  28. "SpoolmanFilamentPatch": "a Spoolman filament, not a database row",
  29. "FilamentProfileDefaultWrite": "replaces a default as a whole; its validator reads a null key field as empty",
  30. }
  31. def test_every_row_update_body_ignores_null_for_required_columns():
  32. from backend.app.main import app
  33. unguarded = []
  34. for route in app.routes:
  35. if not (isinstance(route, APIRoute) and route.methods & {"PATCH", "PUT"} and route.body_field):
  36. continue
  37. schema = route.body_field.field_info.annotation
  38. if not isinstance(schema, type) or not hasattr(schema, "model_fields"):
  39. continue # a list or dict body
  40. if issubclass(schema, IgnoresNullForRequiredColumns) or schema.__name__ in NOT_A_ROW_UPDATE:
  41. continue
  42. unguarded.append(f"{schema.__name__} ({route.path})")
  43. assert unguarded == []
  44. def test_every_declared_table_exists():
  45. from backend.app.main import app
  46. tables = {
  47. route.body_field.field_info.annotation.__orm_table__
  48. for route in app.routes
  49. if isinstance(route, APIRoute)
  50. and route.body_field
  51. and isinstance(route.body_field.field_info.annotation, type)
  52. and issubclass(route.body_field.field_info.annotation, IgnoresNullForRequiredColumns)
  53. }
  54. assert tables
  55. for name in tables:
  56. _table(name) # raises KeyError for a misspelt table
  57. class _PlugUpdate(IgnoresNullForRequiredColumns):
  58. __orm_table__ = "smart_plugs"
  59. name: str | None = None # NOT NULL column
  60. username: str | None = None # nullable column
  61. note_for_client: str | None = None # no column at all
  62. # NOT NULL column; routes write it under the field name, clients may send the alias
  63. off_delay_minutes: int | None = Field(default=None, alias="offDelay")
  64. def test_null_for_a_not_null_column_counts_as_not_sent():
  65. update = _PlugUpdate(name=None, username="x")
  66. assert update.model_dump(exclude_unset=True) == {"username": "x"}
  67. def test_null_for_a_nullable_column_clears_it():
  68. update = _PlugUpdate(username=None)
  69. assert update.model_dump(exclude_unset=True) == {"username": None}
  70. def test_null_for_a_field_with_no_column_is_left_alone():
  71. assert _PlugUpdate(note_for_client=None).note_for_client is None
  72. def test_a_field_sent_under_its_alias_is_checked_by_its_column():
  73. assert _PlugUpdate.model_validate({"offDelay": None}).model_dump(exclude_unset=True) == {}
  74. def test_leaving_a_field_out_is_not_null():
  75. assert _PlugUpdate.model_validate({"username": "x"}).model_dump(exclude_unset=True) == {"username": "x"}