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:
2026-08-27 16:48:15 +03:30
parent b8449a6ff3
commit 5c8310b837
24 changed files with 1735 additions and 118 deletions
+148
View File
@@ -0,0 +1,148 @@
"""Tests for engine.adapter — InMemoryAdapter and StorageAdapter protocol."""
import threading
from engine.adapter import InMemoryAdapter
from engine.types.operation import Operation
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def make_op(**kwargs: object) -> Operation:
defaults: dict[str, object] = {
"type": "insert",
"device_id": "dev-1",
"timestamp": "1000000.0",
"entity_id": "e1",
"entity_type": "obj",
"payload": {"key": "value"},
}
defaults.update(kwargs)
return Operation(**defaults)
# ---------------------------------------------------------------------------
# Initial state
# ---------------------------------------------------------------------------
class TestInMemoryAdapterInit:
def test_cursor_starts_at_zero(self) -> None:
adapter = InMemoryAdapter()
assert adapter.cursor == 0
def test_get_all_returns_empty_on_fresh_adapter(self) -> None:
adapter = InMemoryAdapter()
assert adapter.get_operations_after(0) == []
# ---------------------------------------------------------------------------
# append_operations
# ---------------------------------------------------------------------------
class TestAppendOperations:
def test_returns_new_cursor(self) -> None:
adapter = InMemoryAdapter()
cursor = adapter.append_operations([make_op(), make_op()])
assert cursor == 2
def test_empty_append_returns_current_cursor(self) -> None:
adapter = InMemoryAdapter()
adapter.append_operations([make_op()])
cursor = adapter.append_operations([])
assert cursor == 1
def test_sequential_ids_are_stamped(self) -> None:
adapter = InMemoryAdapter()
adapter.append_operations([make_op(), make_op(), make_op()])
ids = [op.id for op in adapter._log]
assert ids == ["1", "2", "3"]
def test_original_op_id_is_overwritten(self) -> None:
"""Server always assigns its own sequence number."""
adapter = InMemoryAdapter()
adapter.append_operations([make_op(id="client-assigned-id")])
assert adapter._log[0].id == "1"
def test_original_op_fields_preserved(self) -> None:
adapter = InMemoryAdapter()
op = make_op(entity_id="entity-42", payload={"hello": "world"})
adapter.append_operations([op])
stored = adapter._log[0]
assert stored.entity_id == "entity-42"
assert stored.payload == {"hello": "world"}
def test_cursor_advances_incrementally(self) -> None:
adapter = InMemoryAdapter()
c1 = adapter.append_operations([make_op()])
c2 = adapter.append_operations([make_op()])
c3 = adapter.append_operations([make_op()])
assert c1 == 1
assert c2 == 2
assert c3 == 3
# ---------------------------------------------------------------------------
# get_operations_after
# ---------------------------------------------------------------------------
class TestGetOperationsAfter:
def test_cursor_zero_returns_all(self) -> None:
adapter = InMemoryAdapter()
adapter.append_operations([make_op(), make_op()])
ops = adapter.get_operations_after(0)
assert len(ops) == 2
def test_cursor_at_end_returns_empty(self) -> None:
adapter = InMemoryAdapter()
adapter.append_operations([make_op(), make_op()])
ops = adapter.get_operations_after(2)
assert ops == []
def test_cursor_in_middle_returns_tail(self) -> None:
adapter = InMemoryAdapter()
adapter.append_operations([make_op(), make_op(), make_op()])
ops = adapter.get_operations_after(1)
assert len(ops) == 2
assert ops[0].id == "2"
assert ops[1].id == "3"
def test_returns_copy_not_reference(self) -> None:
"""Mutating the returned list must not affect the internal log."""
adapter = InMemoryAdapter()
adapter.append_operations([make_op()])
ops = adapter.get_operations_after(0)
ops.clear()
assert len(adapter._log) == 1
# ---------------------------------------------------------------------------
# Thread safety
# ---------------------------------------------------------------------------
class TestThreadSafety:
def test_concurrent_appends_produce_unique_sequential_ids(self) -> None:
adapter = InMemoryAdapter()
errors: list[Exception] = []
def worker() -> None:
try:
adapter.append_operations([make_op()])
except Exception as exc:
errors.append(exc)
threads = [threading.Thread(target=worker) for _ in range(50)]
for t in threads:
t.start()
for t in threads:
t.join()
assert errors == [], f"Thread errors: {errors}"
assert adapter.cursor == 50
ids = {op.id for op in adapter._log}
assert ids == {str(i) for i in range(1, 51)}