diff --git a/nexanote/sync/client.py b/nexanote/sync/client.py index 103769b..1d341b3 100644 --- a/nexanote/sync/client.py +++ b/nexanote/sync/client.py @@ -722,15 +722,23 @@ def _push_note(self, note: Note, report: SyncReport) -> None: if note.notebook_id: notebook = self.db.get_notebook(note.notebook_id) - if notebook: - nb_slug = _notebook_to_slug(notebook) - else: - # EN: Notes without a notebook go into the default fallback folder. - # FR: Les notes sans carnet sont placées dans le dossier de repli par défaut. - nb_slug = DEFAULT_NOTEBOOK_SLUG - logger.debug(f"Note {note.title!r} has no notebook → using '{nb_slug}' folder") - - note_slug = _note_to_slug(note) + try: + if notebook: + nb_slug = _notebook_to_slug(notebook) + else: + # EN: Notes without a notebook go into the default fallback folder. + # FR: Les notes sans carnet sont placées dans le dossier de repli par défaut. + nb_slug = DEFAULT_NOTEBOOK_SLUG + logger.debug(f"Note {note.title!r} has no notebook → using '{nb_slug}' folder") + note_slug = _note_to_slug(note) + except Exception as exc: + # Slug computation only fails on truly malformed notes (e.g. + # missing id), but if it ever does we want a useful reason + # instead of a raw traceback bubbling up to the sync report. + msg = f"path generation failed: {type(exc).__name__}: {exc}" + logger.exception("Could not compute push path for note %s", note.id) + report.errors.append(f"Échec partiel push : {note.title} — {msg}") + return # Créer le dossier carnet sur le serveur si nécessaire nb_entries = self.client.list_notebooks() @@ -745,17 +753,37 @@ def _push_note(self, note: Note, report: SyncReport) -> None: self.client.create_note_dir(nb_slug, note_slug) # PUT note.json + try: + meta_payload = _serialize_note_meta(note) + except Exception as exc: + logger.exception("note.json serialization failed for %s", note.id) + reason = f"note.json: serialization failed: {type(exc).__name__}: {exc}" + report.errors.append(f"Échec partiel push : {note.title} — {reason}") + return meta_ok, meta_reason = self.client.put_note_meta( - nb_slug, note_slug, _serialize_note_meta(note) + nb_slug, note_slug, meta_payload ) # PUT page_N.ink pour chaque page pages_ok = True page_reasons: list[str] = [] for page in note.pages: + try: + ink_payload = _serialize_ink_page(page) + except Exception as exc: + logger.exception( + "page_%d.ink serialization failed for note %s", + page.page_number, + note.id, + ) + pages_ok = False + page_reasons.append( + f"page {page.page_number}: serialization failed: " + f"{type(exc).__name__}: {exc}" + ) + continue ok, page_reason = self.client.put_ink_page( - nb_slug, note_slug, page.page_number, - _serialize_ink_page(page) + nb_slug, note_slug, page.page_number, ink_payload, ) if not ok: pages_ok = False diff --git a/nexanote/sync/server.py b/nexanote/sync/server.py index 57c9368..25bb00a 100644 --- a/nexanote/sync/server.py +++ b/nexanote/sync/server.py @@ -16,11 +16,12 @@ import logging import sys from pathlib import Path +from typing import Optional from cheroot import wsgi from wsgidav.wsgidav_app import WsgiDAVApp -from nexanote.models.note import Notebook +from nexanote.models.note import Note, Notebook, NoteType, SyncStatus from nexanote.storage import FileNoteStore, run_migration from nexanote.sync.webdav_provider import NexaNoteDAVProvider @@ -72,12 +73,60 @@ def ensure_default_notebook(db: FileNoteStore) -> Notebook: id=f"{DEFAULT_NOTEBOOK_ID_PREFIX}-0000-0000-0000-000000000000", name=DEFAULT_NOTEBOOK_NAME, color="#6b7280", + sync_status=SyncStatus.SYNCED, ) db.save_notebook(notebook) logger.info("Default notebook created: %s", DEFAULT_NOTEBOOK_NAME) return notebook +def seed_demo_data(db: FileNoteStore) -> Optional[Note]: + """ + EN: First-run-only demo content. Creates a sample notebook and a tutorial + note marked as SYNCED so it lives on this server but never gets + re-pushed by a sync engine sharing this data dir. Pull copies it + to clients normally; once a client edits it, the local copy flips + to MODIFIED and the next push round-trips like any user note. + + Skipped if any non-fallback notebook already exists. Returns the + seeded note (or None if nothing was seeded) — useful for tests. + + FR: Contenu de démo créé une seule fois. Marqué SYNCED pour qu'il ne + soit jamais re-poussé par un moteur de sync partageant ce data dir. + """ + has_user_notebook = any( + not nb.id.startswith(DEFAULT_NOTEBOOK_ID_PREFIX) + for nb in db.list_notebooks() + ) + if has_user_notebook: + return None + + notebook = Notebook( + name="Mon premier carnet", + color="#6366f1", + sync_status=SyncStatus.SYNCED, + ) + db.save_notebook(notebook) + + note = Note( + notebook_id=notebook.id, + title="Bienvenue dans NexaNote", + note_type=NoteType.TYPED, + ) + page = note.add_page(template="lined") + page.typed_content = ( + "# Bienvenue dans NexaNote\n\n" + "Cette note a été créée automatiquement.\n" + "Connecte ton app Flutter à ce serveur WebDAV pour commencer à synchroniser tes notes.\n" + ) + # Mark SYNCED so a sync engine sharing this data dir doesn't pick the + # demo note up for push (it already lives on this server). + note.sync_status = SyncStatus.SYNCED + db.save_note(note) + logger.info("Données de démonstration créées") + return note + + def build_app( db: FileNoteStore, username: str = "nexanote", @@ -160,29 +209,7 @@ def run_server( # present, so the very first sync push has valid parent collections. ensure_storage_layout(db) ensure_default_notebook(db) - - # Créer un carnet de démonstration si seul le carnet de repli existe. - notebooks = [ - nb for nb in db.list_notebooks() - if not nb.id.startswith(DEFAULT_NOTEBOOK_ID_PREFIX) - ] - if not notebooks: - from nexanote.models.note import Note, Notebook, NoteType - nb = Notebook(name="Mon premier carnet", color="#6366f1") - db.save_notebook(nb) - note = Note( - notebook_id=nb.id, - title="Bienvenue dans NexaNote", - note_type=NoteType.TYPED, - ) - page = note.add_page(template="lined") - page.typed_content = ( - "# Bienvenue dans NexaNote\n\n" - "Cette note a été créée automatiquement.\n" - "Connecte ton app Flutter à ce serveur WebDAV pour commencer à synchroniser tes notes.\n" - ) - db.save_note(note) - logger.info("Données de démonstration créées") + seed_demo_data(db) app = build_app(db, username=username, password=password, verbose=verbose) diff --git a/nexanote/sync/webdav_provider.py b/nexanote/sync/webdav_provider.py index ca2f3bc..d592914 100644 --- a/nexanote/sync/webdav_provider.py +++ b/nexanote/sync/webdav_provider.py @@ -489,24 +489,33 @@ def __init__( self.note = note def _serialize(self) -> bytes: - data = { - "id": self.note.id, - "title": self.note.title, - "type": self.note.note_type.value, - "tags": self.note.tags, - "is_pinned": self.note.is_pinned, - "created_at": self.note.created_at.isoformat(), - "updated_at": self.note.updated_at.isoformat(), - "pages": [ - { - "page_number": p.page_number, - "template": p.template, - "typed_content": p.typed_content, - } - for p in self.note.pages - ], - } - return json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8") + try: + data = { + "id": self.note.id, + "title": self.note.title, + "type": self.note.note_type.value, + "tags": self.note.tags, + "is_pinned": self.note.is_pinned, + "created_at": self.note.created_at.isoformat(), + "updated_at": self.note.updated_at.isoformat(), + "pages": [ + { + "page_number": p.page_number, + "template": p.template, + "typed_content": p.typed_content, + } + for p in self.note.pages + ], + } + return json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8") + except Exception as exc: + # Surface the underlying reason instead of a bare 500. WsgiDAV + # calls `_serialize` from `get_content_length`/`get_content`, + # which run before `begin_write` on PUT — a crash here would + # otherwise become "WebDAV upload failed: 500 Internal Server + # Error" with no clue about the offending field. + logger.exception("note.json serialization failed for %s", self.note.id) + raise _safe_dav_error(exc, "could not serialize note.json") from exc def get_content_length(self) -> int: return len(self._serialize()) @@ -643,35 +652,45 @@ def __init__( self.note = note def _serialize(self) -> bytes: - data = { - "page_id": self.page.id, - "note_id": self.page.note_id, - "page_number": self.page.page_number, - "template": self.page.template, - "width_px": self.page.width_px, - "height_px": self.page.height_px, - "updated_at": self.page.updated_at.isoformat(), - "strokes": [ - { - "id": s.id, - "color": s.color, - "width": s.width, - "tool": s.tool, - "created_at": s.created_at.isoformat(), - "points": [ - { - "x": p.x, - "y": p.y, - "pressure": p.pressure, - "ts": p.timestamp_ms, - } - for p in s.points - ], - } - for s in self.page.strokes - ], - } - return json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8") + try: + data = { + "page_id": self.page.id, + "note_id": self.page.note_id, + "page_number": self.page.page_number, + "template": self.page.template, + "width_px": self.page.width_px, + "height_px": self.page.height_px, + "updated_at": self.page.updated_at.isoformat(), + "strokes": [ + { + "id": s.id, + "color": s.color, + "width": s.width, + "tool": s.tool, + "created_at": s.created_at.isoformat(), + "points": [ + { + "x": p.x, + "y": p.y, + "pressure": p.pressure, + "ts": p.timestamp_ms, + } + for p in s.points + ], + } + for s in self.page.strokes + ], + } + return json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8") + except Exception as exc: + logger.exception( + "page_%d.ink serialization failed for note %s", + self.page.page_number, + self.note.id, + ) + raise _safe_dav_error( + exc, f"could not serialize page_{self.page.page_number}.ink" + ) from exc def get_content_length(self) -> int: return len(self._serialize()) diff --git a/tests/test_webdav_sync_push.py b/tests/test_webdav_sync_push.py index cf91428..7316f9a 100644 --- a/tests/test_webdav_sync_push.py +++ b/tests/test_webdav_sync_push.py @@ -24,7 +24,7 @@ from cheroot import wsgi as cheroot_wsgi -from nexanote.models.note import Note, Notebook, NoteType +from nexanote.models.note import Note, Notebook, NoteType, SyncStatus from nexanote.storage import FileNoteStore from nexanote.sync.client import ( DEFAULT_NOTEBOOK_SLUG, @@ -37,6 +37,7 @@ build_app, ensure_default_notebook, ensure_storage_layout, + seed_demo_data, ) from nexanote.sync.webdav_provider import ( NexaNoteDAVProvider, @@ -508,3 +509,257 @@ def test_invalid_ink_payload_raises_dav_bad_request(self, tmp_path): writer.write(b"{ this is not json") with pytest.raises(DAVError): writer.close() + + +# --------------------------------------------------------------------------- +# Welcome / demo note sync — the previously-failing 500 case +# --------------------------------------------------------------------------- + + +@pytest.fixture +def live_server_with_demo(tmp_path): + """ + Live WebDAV server bootstrapped exactly like ``run_server`` first launch: + fallback notebook + seeded demo content (notebook + welcome note). + Mirrors the production path that produced the welcome-note 500 report. + """ + db = FileNoteStore(tmp_path / "demo_server") + ensure_storage_layout(db) + ensure_default_notebook(db) + seeded = seed_demo_data(db) + assert seeded is not None, "seed_demo_data must produce a note on first run" + + app = build_app(db, username="user", password="pass", verbose=False) + port = _free_port() + server = cheroot_wsgi.Server( + bind_addr=("127.0.0.1", port), + wsgi_app=app, + numthreads=4, + request_queue_size=8, + ) + thread = threading.Thread(target=server.start, daemon=True) + thread.start() + + base_url = f"http://127.0.0.1:{port}/" + deadline = time.time() + 5 + while time.time() < deadline: + try: + requests.options(base_url, timeout=1) + break + except requests.RequestException: + time.sleep(0.05) + else: + server.stop() + raise RuntimeError("test WebDAV server failed to start") + + yield { + "url": base_url, + "username": "user", + "password": "pass", + "db": db, + "demo_note": seeded, + } + + server.stop() + db.close() + + +class TestDemoSeed: + """ + Demo data must be created in a state that survives a full sync round-trip + without ever round-tripping through a 500. The bug being fixed: a fresh + server with seeded demo data triggered a partial-push failure on the + welcome note when a sync engine ran against the same data dir. + """ + + def test_seed_demo_marks_data_synced(self, tmp_path): + # Demo data must be SYNCED so a sync engine sharing the data dir + # never tries to push it back to itself. + db = FileNoteStore(tmp_path / "seed") + ensure_default_notebook(db) + note = seed_demo_data(db) + assert note is not None + assert note.sync_status == SyncStatus.SYNCED + + notebook = db.get_notebook(note.notebook_id) + assert notebook is not None + assert notebook.sync_status == SyncStatus.SYNCED + + def test_seed_demo_is_idempotent(self, tmp_path): + db = FileNoteStore(tmp_path / "seed2") + ensure_default_notebook(db) + first = seed_demo_data(db) + assert first is not None + second = seed_demo_data(db) + # Second call sees the user notebook and bails out — no duplication. + assert second is None + notebooks = [ + nb for nb in db.list_notebooks() + if not nb.id.startswith(DEFAULT_NOTEBOOK_ID_PREFIX) + ] + assert len(notebooks) == 1 + + +class TestWelcomeNoteSync: + def test_self_sync_does_not_repush_demo_note(self, live_server_with_demo): + """ + EN: When the same data dir backs both the WebDAV server and a sync + engine (the layout used by ``python main.py``), the seeded + welcome note must not be picked up for push. Previously its + LOCAL_ONLY status caused the engine to PUT note.json + page_1.ink + against itself, and any failure surfaced as a useless 500. + """ + engine = NexaNoteSyncEngine( + live_server_with_demo["db"], + SyncConfig( + server_url=live_server_with_demo["url"], + username=live_server_with_demo["username"], + password=live_server_with_demo["password"], + timeout_seconds=5, + ), + ) + report = engine.sync() + assert report.success(), f"self-sync should be a no-op: {report.errors}" + assert report.notes_pushed == 0, ( + f"seeded demo data must stay put on self-sync, got {report.notes_pushed}" + ) + + def test_fresh_client_pulls_welcome_note_as_synced( + self, live_server_with_demo, tmp_path + ): + """ + EN: A fresh client pulling from a server with seeded demo content + must receive the welcome note locally, marked SYNCED. A second + sync against an unchanged server must be a no-op — nothing to + push, nothing to pull. + """ + client_db = FileNoteStore(tmp_path / "client_welcome") + engine = NexaNoteSyncEngine( + client_db, + SyncConfig( + server_url=live_server_with_demo["url"], + username=live_server_with_demo["username"], + password=live_server_with_demo["password"], + timeout_seconds=5, + ), + ) + + # 1) Pull — welcome note arrives locally with SYNCED status. + report = engine.sync() + assert report.success(), f"pull failed: {report.errors}" + assert report.notes_pulled == 1 + assert report.notes_pushed == 0 + + demo_id = live_server_with_demo["demo_note"].id + local = client_db.get_note(demo_id, load_pages=True) + assert local is not None, "welcome note should be pulled to client" + assert local.title == live_server_with_demo["demo_note"].title + assert local.sync_status == SyncStatus.SYNCED + assert local.pages and local.pages[0].typed_content + + # 2) Second sync — fully steady-state, no spurious push of the + # demo note (which would have triggered the 500 in the bug report). + steady = engine.sync() + assert steady.success(), f"steady-state sync errors: {steady.errors}" + assert steady.notes_pushed == 0 + + def test_welcome_note_push_from_fresh_client_to_empty_server( + self, live_server, tmp_path + ): + """ + EN: A separate scenario: the client locally creates a welcome note + (e.g. on first launch) and pushes to a server that doesn't yet + have one. Title/path must serialize and travel without a 500. + Title is taken from the seeded demo so the test isn't pinned + to a hardcoded literal. + """ + seed_db = FileNoteStore(tmp_path / "seed_for_title") + ensure_default_notebook(seed_db) + seeded = seed_demo_data(seed_db) + assert seeded is not None + welcome_title = seeded.title + + client_db = FileNoteStore(tmp_path / "client_fresh_welcome") + nb = Notebook(name="Mon premier carnet", color="#6366f1") + client_db.save_notebook(nb) + note = Note( + notebook_id=nb.id, + title=welcome_title, + note_type=NoteType.TYPED, + ) + page = note.add_page(template="lined") + page.typed_content = ( + f"# {welcome_title}\n\nCette note a été créée automatiquement.\n" + ) + client_db.save_note(note) + + engine = NexaNoteSyncEngine( + client_db, + SyncConfig( + server_url=live_server["url"], + username=live_server["username"], + password=live_server["password"], + timeout_seconds=5, + ), + ) + report = engine.sync() + assert report.success(), f"welcome-note push failed: {report.errors}" + assert report.notes_pushed == 1 + + server_note = live_server["db"].get_note(note.id, load_pages=True) + assert server_note is not None + assert server_note.title == welcome_title + + +class TestPushSerializationFailures: + """ + EN: When path generation or payload serialization blows up, the sync + report must surface a useful reason — never a bare 500/traceback. + """ + + def test_push_reports_serialization_error_with_reason(self, tmp_path): + from nexanote.sync.client import SyncReport + + db = FileNoteStore(tmp_path / "ser_fail") + nb = Notebook(name="Carnet") + db.save_notebook(nb) + note = Note(notebook_id=nb.id, title="Bad note", note_type=NoteType.TYPED) + note.add_page() + db.save_note(note) + + engine = NexaNoteSyncEngine( + db, + SyncConfig( + server_url="http://localhost:9999/", + username="u", + password="p", + ), + ) + + # Stub path-bound network calls so the test never hits the wire, + # then make `_serialize_note_meta` blow up to force the error path. + engine.client.list_notebooks = lambda: [] + engine.client.list_notes = lambda nb_slug: [] + engine.client.create_notebook_dir = lambda nb_slug: True + engine.client.create_note_dir = lambda nb_slug, note_slug: True + engine.client.put_note_meta = lambda *a, **k: (True, None) + engine.client.put_ink_page = lambda *a, **k: (True, None) + + from nexanote.sync import client as client_module + + original = client_module._serialize_note_meta + try: + def boom(_note): + raise ValueError("simulated serialization bug") + + client_module._serialize_note_meta = boom + report = SyncReport() + engine._push_note(note, report) + finally: + client_module._serialize_note_meta = original + + assert report.errors, "serialization failure must produce a report error" + msg = report.errors[0] + assert "Bad note" in msg + assert "serialization failed" in msg + assert "simulated serialization bug" in msg