feat: add StorageAdapter abstraction and InMemoryAdapter with full test suite
- Introduce StorageAdapter protocol and InMemoryAdapter (thread-safe, in-memory) in engine/adapter.py to decouple storage from sync logic - Refactor SyncEngine to accept a StorageAdapter, replacing the previous stub that returned an empty list - Wire server to use SyncEngine + InMemoryAdapter, removing hardcoded SYNC_OPERATIONS fixtures - Add tests for engine, adapter, generator, and types (tests/) - Add pre-commit config (.pre-commit-config.yaml) with linting/formatting hooks - Update pyproject.toml and uv.lock with new dev dependencies - Clean up type annotations and CLI help strings across generator, server, and utils
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
"""Tests for all Pydantic models — encode/decode roundtrips and field contracts."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from engine.types.operation import Operation
|
||||
from engine.types.snapshot import Snapshot
|
||||
from server.types.sync_request import SyncRequest
|
||||
from server.types.sync_response import SyncResponse
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Operation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestOperation:
|
||||
def _full_op(self) -> Operation:
|
||||
return Operation(
|
||||
id="op-1",
|
||||
type="insert",
|
||||
device_id="dev-1",
|
||||
timestamp="1000000.0",
|
||||
entity_id="entity-42",
|
||||
entity_type="object",
|
||||
payload={"key": "value", "count": 3},
|
||||
)
|
||||
|
||||
def test_encode_returns_bytes(self) -> None:
|
||||
assert isinstance(self._full_op().encode(), bytes)
|
||||
|
||||
def test_encode_decode_roundtrip(self) -> None:
|
||||
op = self._full_op()
|
||||
assert Operation.decode(op.encode()) == op
|
||||
|
||||
def test_all_fields_optional_allows_empty_instance(self) -> None:
|
||||
op = Operation()
|
||||
assert op.id is None
|
||||
assert op.type is None
|
||||
|
||||
def test_decode_partial_json(self) -> None:
|
||||
"""Only required fields in bytes → remaining fields default to None."""
|
||||
raw = b'{"id": "x"}'
|
||||
op = Operation.decode(raw)
|
||||
assert op.id == "x"
|
||||
assert op.type is None
|
||||
|
||||
def test_encode_is_utf8_json(self) -> None:
|
||||
op = self._full_op()
|
||||
parsed = json.loads(op.encode().decode("utf-8"))
|
||||
assert parsed["id"] == "op-1"
|
||||
assert parsed["payload"] == {"key": "value", "count": 3}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Snapshot
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSnapshot:
|
||||
def _full_snap(self) -> Snapshot:
|
||||
return Snapshot(id="snap-1", cursor=10, data={"users": []}, timestamp=1000)
|
||||
|
||||
def test_encode_returns_bytes(self) -> None:
|
||||
assert isinstance(self._full_snap().encode(), bytes)
|
||||
|
||||
def test_encode_decode_roundtrip(self) -> None:
|
||||
snap = self._full_snap()
|
||||
assert Snapshot.decode(snap.encode()) == snap
|
||||
|
||||
def test_all_fields_optional(self) -> None:
|
||||
snap = Snapshot()
|
||||
assert snap.id is None
|
||||
assert snap.cursor is None
|
||||
assert snap.data is None
|
||||
assert snap.timestamp is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SyncRequest
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSyncRequest:
|
||||
def _make(self, cursor: int = 0, ops: list[Operation] | None = None) -> SyncRequest:
|
||||
return SyncRequest(
|
||||
device_id="client-1",
|
||||
cursor=cursor,
|
||||
operations=ops or [],
|
||||
)
|
||||
|
||||
def test_encode_returns_bytes(self) -> None:
|
||||
assert isinstance(self._make().encode(), bytes)
|
||||
|
||||
def test_encode_decode_roundtrip_empty_ops(self) -> None:
|
||||
req = self._make(cursor=5)
|
||||
assert SyncRequest.decode(req.encode()) == req
|
||||
|
||||
def test_encode_decode_roundtrip_with_ops(self) -> None:
|
||||
op = Operation(
|
||||
id="1",
|
||||
type="insert",
|
||||
device_id="client-1",
|
||||
timestamp="1.0",
|
||||
entity_id="e1",
|
||||
entity_type="obj",
|
||||
payload={},
|
||||
)
|
||||
req = self._make(cursor=0, ops=[op])
|
||||
decoded = SyncRequest.decode(req.encode())
|
||||
assert decoded.device_id == "client-1"
|
||||
assert len(decoded.operations) == 1
|
||||
assert decoded.operations[0].id == "1"
|
||||
|
||||
def test_required_fields_enforced(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
SyncRequest(cursor=0, operations=[]) # type: ignore[call-arg]
|
||||
|
||||
def test_cursor_required(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
SyncRequest(device_id="x", operations=[]) # type: ignore[call-arg]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SyncResponse
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSyncResponse:
|
||||
def _make(
|
||||
self,
|
||||
cursor: int = 0,
|
||||
ops: list[Operation] | None = None,
|
||||
snapshot: Snapshot | None = None,
|
||||
) -> SyncResponse:
|
||||
return SyncResponse(cursor=cursor, operations=ops or [], snapshot=snapshot)
|
||||
|
||||
def test_encode_returns_bytes(self) -> None:
|
||||
assert isinstance(self._make().encode(), bytes)
|
||||
|
||||
def test_encode_decode_roundtrip_no_snapshot(self) -> None:
|
||||
res = self._make(cursor=3)
|
||||
assert SyncResponse.decode(res.encode()) == res
|
||||
|
||||
def test_encode_decode_roundtrip_with_snapshot(self) -> None:
|
||||
snap = Snapshot(id="s1", cursor=3, data={}, timestamp=999)
|
||||
res = self._make(cursor=3, snapshot=snap)
|
||||
decoded = SyncResponse.decode(res.encode())
|
||||
assert decoded.snapshot is not None
|
||||
assert decoded.snapshot.id == "s1"
|
||||
|
||||
def test_snapshot_defaults_to_none(self) -> None:
|
||||
res = self._make()
|
||||
assert res.snapshot is None
|
||||
|
||||
def test_operations_carries_nested_ops(self) -> None:
|
||||
op = Operation(
|
||||
id="2",
|
||||
type="delete",
|
||||
device_id="dev-1",
|
||||
timestamp="2.0",
|
||||
entity_id="e2",
|
||||
entity_type="obj",
|
||||
payload={},
|
||||
)
|
||||
res = self._make(cursor=1, ops=[op])
|
||||
decoded = SyncResponse.decode(res.encode())
|
||||
assert decoded.operations[0].type == "delete"
|
||||
Reference in New Issue
Block a user