DRY: - serialize.py: serialize_label_sync(label) = base serialize_label + the delta-only fields (sync_revision/purged_at/created_at via iso()). sync's changes() adopts it; the local _serialize_label_row near-dup is gone. - sync adopts common.parse_dt (drops the byte-identical _parse_client_dt; 4 call sites) and common.iso for the note delta augmentation. (Manual-label reconciliation was already shared in S3.) Fully folding the note re-augmentation into the serializer waits on the notes.py split. - test_sync: drops the now-redundant _parse_client_dt test (parse_dt is covered in test_notes) + its dead import. Security (issue — push existence-oracle): a foreign-owned id on push was rejected with "not yours", distinguishing "another user's note" from a free id. A legit client only pushes ids of notes it created, so that branch is only hit by a probe (or ~0-prob UUID collision) — now a GENERIC "cannot apply" rejection that doesn't confirm the id exists. The residual create-vs-reject status difference is inherent to client-chosen ids over a global PK and is practically unexploitable (a shared note already exposes its id to recipients). Sync behavior operator-verified on deploy (no Postgres CI lane). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
85 lines
2.6 KiB
Python
85 lines
2.6 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_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_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
|