From 15c4cef36a69e9cda65244566dc3ff8ee013b1fb Mon Sep 17 00:00:00 2001 From: robyroro <41092915+robyroro@users.noreply.github.com> Date: Wed, 7 Oct 2026 19:50:03 +0300 Subject: [PATCH] fix(notes): honor explicit null when clearing optional fields --- routes/note/note_routes.py | 21 +++--- tests/test_notes_clear_optional_fields.py | 82 +++++++++++++++++++++++ 2 files changed, 94 insertions(+), 9 deletions(-) create mode 100644 tests/test_notes_clear_optional_fields.py diff --git a/routes/note/note_routes.py b/routes/note/note_routes.py index 249453614..cabf6a2bb 100644 --- a/routes/note/note_routes.py +++ b/routes/note/note_routes.py @@ -728,32 +728,35 @@ def setup_note_routes(task_scheduler=None, upload_handler=None): ) if body.title is not None: note.title = body.title - if body.content is not None: + # Omitted fields preserve their values; explicit null clears the + # nullable fields used by the editor's removal controls. + supplied = body.model_fields_set + if "content" in supplied: note.content = body.content - if body.items is not None: - note.items = json.dumps(body.items) + if "items" in supplied: + note.items = json.dumps(body.items) if body.items is not None else None flag_modified(note, "items") if body.note_type is not None: note.note_type = body.note_type - if body.color is not None: + if "color" in supplied: note.color = body.color - if body.label is not None: + if "label" in supplied: note.label = body.label if body.pinned is not None: note.pinned = body.pinned if body.archived is not None: note.archived = body.archived - if body.due_date is not None: + if "due_date" in supplied: note.due_date = body.due_date - if body.image_url is not None: + if "image_url" in supplied: note.image_url = body.image_url - if body.gallery_id is not None: + if "gallery_id" in supplied: note.gallery_id = body.gallery_id if body.repeat is not None: note.repeat = body.repeat if body.sort_order is not None: note.sort_order = body.sort_order - if body.agent_session_id is not None: + if "agent_session_id" in supplied: note.agent_session_id = body.agent_session_id db.commit() diff --git a/tests/test_notes_clear_optional_fields.py b/tests/test_notes_clear_optional_fields.py new file mode 100644 index 000000000..96041e8fe --- /dev/null +++ b/tests/test_notes_clear_optional_fields.py @@ -0,0 +1,82 @@ +"""Explicit null must clear nullable note fields; omission must preserve them.""" + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from tests.helpers.database import disposable_database + + +@pytest.fixture +def client(tmp_path, monkeypatch): + import routes.note.note_routes as routes + + with disposable_database(tmp_path) as factory: + monkeypatch.setattr(routes, "SessionLocal", factory) + app = FastAPI() + + @app.middleware("http") + async def identity(request, call_next): + request.state.current_user = request.headers.get("x-test-user", "alice") + return await call_next(request) + + monkeypatch.setattr(routes, "_scheduler_ref", None) + app.include_router(routes.setup_note_routes()) + with TestClient(app) as api: + yield api + + +@pytest.mark.parametrize("field,value", [ + ("due_date", "2030-10-08T09:00:00"), + ("label", "home"), + ("image_url", "https://example.com/note.png"), + ("content", "Saved body"), + ("items", [{"text": "Saved item", "done": False}]), + ("color", "blue"), + ("gallery_id", "gallery-1"), + ("agent_session_id", "chat-1"), +]) +def test_explicit_null_clears_saved_field(client, field, value): + created = client.post("/api/notes", json={"title": "Test note"}) + assert created.status_code == 200, created.text + path = f"/api/notes/{created.json()['id']}" + populated = client.put(path, json={field: value}) + assert populated.status_code == 200, populated.text + assert client.get(path).json()[field] == value + + updated = client.put(path, json={field: None}) + assert updated.status_code == 200, updated.text + saved = client.get(path) + assert saved.status_code == 200, saved.text + assert saved.json()[field] is None + assert updated.json()[field] is None + + +def test_omitted_fields_survive_title_edit(client): + fields = { + "due_date": "2030-10-08T09:00:00", + "label": "home", + "image_url": "https://example.com/note.png", + "content": "Saved body", + "items": [{"text": "Saved item", "done": False}], + "color": "blue", + "gallery_id": "gallery-1", + } + created = client.post("/api/notes", json={"title": "Before", **fields}) + assert created.status_code == 200, created.text + path = f"/api/notes/{created.json()['id']}" + updated = client.put(path, json={"title": "After"}) + assert updated.status_code == 200, updated.text + saved = client.get(path).json() + assert saved["title"] == "After" + for field, value in fields.items(): + assert saved[field] == value + + +def test_null_update_cannot_clear_another_users_note(client): + created = client.post("/api/notes", json={"title": "Private", "label": "home"}) + assert created.status_code == 200, created.text + path = f"/api/notes/{created.json()['id']}" + response = client.put(path, json={"label": None}, headers={"x-test-user": "bob"}) + assert response.status_code == 404 + assert client.get(path).json()["label"] == "home"