test_patch_null_on_required_fields.py 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133
  1. """An explicit null for a field that can't be empty leaves it unchanged.
  2. Update schemas declare most fields ``X | None = None`` so a PATCH can leave
  3. them out, and most routes treat null as "unchanged". Routes that apply
  4. ``model_dump(exclude_unset=True)`` with setattr instead wrote a JSON null
  5. straight into a NOT NULL column, and the request ended in a 500. The schema
  6. now drops such a null before the route sees it, so every route treats it as
  7. "unchanged"; a null for a column that may be empty still clears it.
  8. """
  9. import pytest
  10. from httpx import AsyncClient
  11. @pytest.mark.asyncio
  12. @pytest.mark.integration
  13. async def test_smart_plug(async_client: AsyncClient, smart_plug_factory, db_session):
  14. plug = await smart_plug_factory(username="admin", controls_printer_power=False)
  15. kept = await async_client.patch(f"/api/v1/smart-plugs/{plug.id}", json={"controls_printer_power": None})
  16. cleared = await async_client.patch(f"/api/v1/smart-plugs/{plug.id}", json={"username": None})
  17. assert kept.status_code == 200, kept.text
  18. assert kept.json()["controls_printer_power"] is False
  19. assert cleared.status_code == 200, cleared.text
  20. assert cleared.json()["username"] is None
  21. @pytest.mark.asyncio
  22. @pytest.mark.integration
  23. async def test_printer(async_client: AsyncClient, printer_factory, db_session):
  24. printer = await printer_factory(location="Shelf")
  25. kept = await async_client.patch(f"/api/v1/printers/{printer.id}", json={"camera_light_auto": None})
  26. cleared = await async_client.patch(f"/api/v1/printers/{printer.id}", json={"location": None})
  27. assert kept.status_code == 200, kept.text
  28. assert kept.json()["camera_light_auto"] is False
  29. assert cleared.status_code == 200, cleared.text
  30. assert cleared.json()["location"] is None
  31. @pytest.mark.asyncio
  32. @pytest.mark.integration
  33. async def test_archive(async_client: AsyncClient, printer_factory, archive_factory, db_session):
  34. printer = await printer_factory()
  35. archive = await archive_factory(printer.id, notes="first", quantity=3)
  36. kept = await async_client.patch(f"/api/v1/archives/{archive.id}", json={"quantity": None})
  37. cleared = await async_client.patch(f"/api/v1/archives/{archive.id}", json={"notes": None})
  38. assert kept.status_code == 200, kept.text
  39. assert kept.json()["quantity"] == 3
  40. assert cleared.status_code == 200, cleared.text
  41. assert cleared.json()["notes"] is None
  42. @pytest.mark.asyncio
  43. @pytest.mark.integration
  44. async def test_notification_provider(async_client: AsyncClient, notification_provider_factory, db_session):
  45. provider = await notification_provider_factory(name="Phone")
  46. kept = await async_client.patch(f"/api/v1/notifications/{provider.id}", json={"name": None})
  47. assert kept.status_code == 200, kept.text
  48. assert kept.json()["name"] == "Phone"
  49. async def _queue_item(db_session, printer_factory, archive_factory):
  50. from backend.app.models.print_queue import PrintQueueItem
  51. printer = await printer_factory()
  52. archive = await archive_factory(printer.id)
  53. item = PrintQueueItem(printer_id=printer.id, archive_id=archive.id, position=1, bed_levelling="off")
  54. db_session.add(item)
  55. await db_session.commit()
  56. return item
  57. @pytest.mark.asyncio
  58. @pytest.mark.integration
  59. async def test_queue_item(async_client: AsyncClient, printer_factory, archive_factory, db_session):
  60. item = await _queue_item(db_session, printer_factory, archive_factory)
  61. kept = await async_client.patch(f"/api/v1/queue/{item.id}", json={"bed_levelling": None})
  62. assert kept.status_code == 200, kept.text
  63. assert kept.json()["bed_levelling"] == "off"
  64. @pytest.mark.asyncio
  65. @pytest.mark.integration
  66. async def test_queue_bulk_update(async_client: AsyncClient, printer_factory, archive_factory, db_session):
  67. item = await _queue_item(db_session, printer_factory, archive_factory)
  68. response = await async_client.patch(
  69. "/api/v1/queue/bulk", json={"item_ids": [item.id], "flow_cali": None, "bed_levelling": "on"}
  70. )
  71. assert response.status_code == 200, response.text
  72. await db_session.refresh(item)
  73. assert item.bed_levelling == "on"
  74. assert item.flow_cali == "auto"
  75. @pytest.mark.asyncio
  76. @pytest.mark.integration
  77. async def test_github_backup_config(async_client: AsyncClient, db_session):
  78. from backend.app.models.github_backup import GitHubBackupConfig
  79. db_session.add(GitHubBackupConfig(repository_url="https://github.com/o/r", access_token="t", branch="main"))
  80. await db_session.commit()
  81. kept = await async_client.patch("/api/v1/github-backup/config", json={"provider": None})
  82. assert kept.status_code == 200, kept.text
  83. assert kept.json()["provider"] == "github"
  84. @pytest.mark.asyncio
  85. @pytest.mark.integration
  86. async def test_a_null_next_to_a_real_change_does_not_block_it(async_client: AsyncClient, db_session):
  87. """On the routes that already skipped a null (projects among them) the rest
  88. of the request was applied; it must stay that way."""
  89. created = await async_client.post("/api/v1/projects/", json={"name": "Shelf"})
  90. assert created.status_code == 200, created.text
  91. project_id = created.json()["id"]
  92. response = await async_client.patch(f"/api/v1/projects/{project_id}", json={"name": None, "notes": "x"})
  93. assert response.status_code == 200, response.text
  94. assert response.json()["name"] == "Shelf"
  95. assert response.json()["notes"] == "x"