test_not_null_update_schemas.py 3.9 KB

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