The core conflict-resolution step. Applies a batch of client changes,
additive + owner-scoped, with last-write-wins by client edit-time — and a
version-history snapshot on every overwrite so nothing is ever lost.
client_wins(client_edited_at, server_edited_at): apply iff client >= server;
a missing client time never overwrites a real server edit; a missing server
time (new row) yields. Notes compare against updated_at; labels gain an
updated_at (migration 0017, backfilled from created_at) as their LWW field.
Notes:
- upsert with a client-supplied id: create if absent, else LWW-apply the
full note state (title/body/color/kind/pins/trash/remind/position/items/
manual label_ids) with the same ripple as a web edit — derive_display_title,
_rewrite_links, _reconcile_tags (#tags), _rename_inbound_links. Overwriting
an existing title/body snapshots the old version into note_revisions first.
A resurrected tombstone clears purged_at.
- delete: purge tombstone (drop children + attachment files, clear content,
set purged_at), LWW-guarded so a newer server edit survives a stale delete.
Labels: upsert (create/rename/recolor) + delete (detach from notes, tombstone),
LWW-guarded; per-owner name-uniqueness clash on a different id is rejected
rather than raising.
Response: per-item {status: created|applied|kept|noop|rejected, sync_revision};
the client pulls afterward to converge. Whole-note semantics (client sends the
full state, not a partial patch).
Tests (DB-free): client_wins across all edit-time combinations; _parse_client_dt;
push auth-guard. Apply behavior + triggers operator-verified on deploy.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
94 lines
2.9 KiB
Python
94 lines
2.9 KiB
Python
from datetime import datetime, timezone
|
|
|
|
import pytest
|
|
|
|
from thoughtsync.app import create_app
|
|
from thoughtsync.sync import (
|
|
DEFAULT_LIMIT,
|
|
MAX_LIMIT,
|
|
_clamp_limit,
|
|
_page_cursor,
|
|
_parse_client_dt,
|
|
_parse_since,
|
|
client_wins,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def app():
|
|
return create_app()
|
|
|
|
|
|
async def test_changes_requires_auth(app):
|
|
client = app.test_client()
|
|
resp = await client.get("/api/sync/changes")
|
|
assert resp.status_code == 401
|
|
|
|
|
|
async def test_push_requires_auth(app):
|
|
client = app.test_client()
|
|
resp = await client.post("/api/sync/push", json={"changes": []})
|
|
assert resp.status_code == 401
|
|
|
|
|
|
def test_client_wins():
|
|
older = datetime(2026, 7, 20, tzinfo=timezone.utc)
|
|
newer = datetime(2026, 7, 22, tzinfo=timezone.utc)
|
|
assert client_wins(newer, older) is True # newer client edit wins
|
|
assert client_wins(older, newer) is False # older client edit loses (server kept)
|
|
assert client_wins(older, older) is True # tie → client applies (idempotent)
|
|
assert client_wins(None, older) is False # unknown client time can't overwrite a real edit
|
|
assert client_wins(older, None) is True # new/unknown server side yields
|
|
assert client_wins(None, None) is True
|
|
|
|
|
|
def test_parse_client_dt():
|
|
assert _parse_client_dt("2026-07-22T00:00:00Z").year == 2026
|
|
assert _parse_client_dt("2026-07-22T00:00:00+00:00").tzinfo is not None
|
|
assert _parse_client_dt("garbage") is None
|
|
assert _parse_client_dt(None) is None
|
|
assert _parse_client_dt(123) is None
|
|
|
|
|
|
def test_parse_since():
|
|
assert _parse_since(None) == 0
|
|
assert _parse_since("42") == 42
|
|
assert _parse_since("-5") == 0 # negative clamps to 0
|
|
assert _parse_since("garbage") == 0
|
|
|
|
|
|
def test_clamp_limit():
|
|
assert _clamp_limit(None) == DEFAULT_LIMIT
|
|
assert _clamp_limit("10") == 10
|
|
assert _clamp_limit("0") == 1 # floor of 1
|
|
assert _clamp_limit("999999") == MAX_LIMIT
|
|
assert _clamp_limit("nope") == DEFAULT_LIMIT
|
|
|
|
|
|
def test_page_cursor_all_drained():
|
|
# Neither stream is full → cursor is the max revision seen; nothing more to page.
|
|
cursor, more = _page_cursor([1, 3, 5], [2, 4], since=0, limit=500)
|
|
assert cursor == 5
|
|
assert more is False
|
|
|
|
|
|
def test_page_cursor_empty():
|
|
# No changes since the cursor → cursor stays put, no more pages.
|
|
cursor, more = _page_cursor([], [], since=7, limit=500)
|
|
assert cursor == 7
|
|
assert more is False
|
|
|
|
|
|
def test_page_cursor_one_stream_full_advances_to_its_boundary():
|
|
# Notes came back full (limit=3) → truncate at its boundary; later labels defer.
|
|
cursor, more = _page_cursor([1, 2, 3], [4, 5], since=0, limit=3)
|
|
assert cursor == 3
|
|
assert more is True
|
|
|
|
|
|
def test_page_cursor_both_full_uses_min_boundary():
|
|
# Both full → advance only to the SMALLER boundary so neither stream skips a gap.
|
|
cursor, more = _page_cursor([1, 2, 10], [3, 4, 5], since=0, limit=3)
|
|
assert cursor == 5
|
|
assert more is True
|