Files
aazerra 5c8310b837 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
2026-08-27 16:48:15 +03:30

170 lines
5.5 KiB
Python

"""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"