ff9c6540a2
Implement entity synchronization between client and server using the _cleveragents/sync/* A2A extension methods per the specification. Sync models (src/cleveragents/a2a/sync_models.py): - Pydantic v2 models for pull/push/status requests and responses - VectorClock with increment, merge, happens-before, and concurrency detection for distributed conflict detection - SyncEntitySnapshot, SyncConflict, SyncState, SyncQueueEntry models - SyncEntityType enum: actor, skill, action, plan, session - ConflictResolution enum: last_writer_wins, manual, server_wins, client_wins SyncService (src/cleveragents/application/services/sync_service.py): - pull(): download server namespace entities to local cache with incremental sync (since timestamps) and force-overwrite option - push(): push local entity definitions to server namespace with configurable conflict resolution strategy - status(): compare local and server entity versions, detect drift, report local-ahead, server-ahead, and concurrent conflicts - resolve_conflict(): manually resolve detected conflicts with winner selection (local or server) - Offline queue: enqueue_offline() and process_offline_queue() for retry on reconnect, with max-retries enforcement - The local/ namespace is never synced per specification A2A facade (src/cleveragents/a2a/facade.py): - Replaced sync stub handlers with real _handle_sync_pull, _handle_sync_push, _handle_sync_status routing to SyncService - Added sync_service property and TYPE_CHECKING import - Facade returns stubs when no SyncService is registered Tests: - Behave BDD: features/entity_sync.feature (65 scenarios, 247 steps) covering models, vector clocks, pull/push/status, conflict resolution, offline queue, facade integration, constructor validation, edge cases - Robot Framework: robot/entity_sync.robot (8 integration tests) ISSUES CLOSED: #866
1177 lines
39 KiB
Python
1177 lines
39 KiB
Python
"""Step definitions for entity_sync.feature.
|
|
|
|
Tests the ``_cleveragents/sync/*`` entity synchronization service,
|
|
including vector clock operations, pull/push/status workflows,
|
|
conflict resolution, offline queue, and facade integration.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import Any
|
|
|
|
from behave import given, then, when
|
|
from pydantic import ValidationError as PydanticValidationError
|
|
|
|
from cleveragents.a2a.facade import A2aLocalFacade
|
|
from cleveragents.a2a.models import A2aRequest
|
|
from cleveragents.a2a.sync_models import (
|
|
ConflictResolution,
|
|
SyncConflict,
|
|
SyncDirection,
|
|
SyncEntitySnapshot,
|
|
SyncEntityType,
|
|
SyncOperationStatus,
|
|
SyncPullRequest,
|
|
SyncPushRequest,
|
|
SyncQueueEntry,
|
|
SyncState,
|
|
SyncStatusRequest,
|
|
VectorClock,
|
|
)
|
|
from cleveragents.application.services.sync_service import SyncService
|
|
from cleveragents.core.exceptions import BusinessRuleViolation, ValidationError
|
|
|
|
# ------------------------------------------------------------------
|
|
# Vector clock steps
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
@given("a vector clock with entries {entries_json}")
|
|
def step_create_vector_clock(context: Any, entries_json: str) -> None:
|
|
entries = json.loads(entries_json)
|
|
context.clock1 = VectorClock(entries=entries)
|
|
|
|
|
|
@given("another vector clock with entries {entries_json}")
|
|
def step_create_second_vector_clock(context: Any, entries_json: str) -> None:
|
|
entries = json.loads(entries_json)
|
|
context.clock2 = VectorClock(entries=entries)
|
|
|
|
|
|
@given("an empty vector clock")
|
|
def step_create_empty_clock(context: Any) -> None:
|
|
context.clock1 = VectorClock()
|
|
|
|
|
|
@when('I increment the clock for node "{node_id}"')
|
|
def step_increment_clock(context: Any, node_id: str) -> None:
|
|
context.result_clock = context.clock1.increment(node_id)
|
|
|
|
|
|
@when("I merge the two clocks")
|
|
def step_merge_clocks(context: Any) -> None:
|
|
context.result_clock = context.clock1.merge(context.clock2)
|
|
|
|
|
|
@then('the clock entry for "{node_id}" should be {value:d}')
|
|
def step_check_clock_entry(context: Any, node_id: str, value: int) -> None:
|
|
assert context.result_clock.entries.get(node_id) == value, (
|
|
f"Expected {value} for {node_id}, "
|
|
f"got {context.result_clock.entries.get(node_id)}"
|
|
)
|
|
|
|
|
|
@then("the merged clock should have entries {entries_json}")
|
|
def step_check_merged_entries(context: Any, entries_json: str) -> None:
|
|
expected = json.loads(entries_json)
|
|
assert context.result_clock.entries == expected, (
|
|
f"Expected {expected}, got {context.result_clock.entries}"
|
|
)
|
|
|
|
|
|
@then("the first clock should happen before the second")
|
|
def step_clock_happens_before(context: Any) -> None:
|
|
assert context.clock1.happens_before(context.clock2), (
|
|
"Expected clock1 to happen before clock2"
|
|
)
|
|
|
|
|
|
@then("the clocks should be concurrent")
|
|
def step_clocks_concurrent(context: Any) -> None:
|
|
assert context.clock1.is_concurrent(context.clock2), (
|
|
"Expected clocks to be concurrent"
|
|
)
|
|
|
|
|
|
@then("the clocks should not be concurrent")
|
|
def step_clocks_not_concurrent(context: Any) -> None:
|
|
assert not context.clock1.is_concurrent(context.clock2), (
|
|
"Expected clocks to NOT be concurrent"
|
|
)
|
|
|
|
|
|
@when("I try to increment the clock with an empty node_id")
|
|
def step_increment_empty_node(context: Any) -> None:
|
|
context.caught_exception = None
|
|
try:
|
|
context.clock1.increment("")
|
|
except ValueError as exc:
|
|
context.caught_exception = exc
|
|
|
|
|
|
@when("I try to merge with a non-VectorClock value")
|
|
def step_merge_non_clock(context: Any) -> None:
|
|
context.caught_exception = None
|
|
try:
|
|
context.clock1.merge("not a clock") # type: ignore[arg-type]
|
|
except TypeError as exc:
|
|
context.caught_exception = exc
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Model validation steps
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
@when("I try to create a snapshot with an empty entity_id")
|
|
def step_create_bad_snapshot(context: Any) -> None:
|
|
context.caught_exception = None
|
|
try:
|
|
SyncEntitySnapshot(
|
|
entity_id="",
|
|
entity_type=SyncEntityType.ACTOR,
|
|
namespace="test",
|
|
name="test",
|
|
)
|
|
except (PydanticValidationError, ValueError) as exc:
|
|
context.caught_exception = exc
|
|
|
|
|
|
@when('I try to create a pull request with namespace "local"')
|
|
def step_create_pull_local(context: Any) -> None:
|
|
context.caught_exception = None
|
|
try:
|
|
SyncPullRequest(namespace="local")
|
|
except (PydanticValidationError, ValueError) as exc:
|
|
context.caught_exception = exc
|
|
|
|
|
|
@when('I try to create a push request with namespace "local"')
|
|
def step_create_push_local(context: Any) -> None:
|
|
context.caught_exception = None
|
|
try:
|
|
SyncPushRequest(namespace="local")
|
|
except (PydanticValidationError, ValueError) as exc:
|
|
context.caught_exception = exc
|
|
|
|
|
|
@when('I try to create a status request with namespace "local"')
|
|
def step_create_status_local(context: Any) -> None:
|
|
context.caught_exception = None
|
|
try:
|
|
SyncStatusRequest(namespace="local")
|
|
except (PydanticValidationError, ValueError) as exc:
|
|
context.caught_exception = exc
|
|
|
|
|
|
@when("I try to create a conflict with an empty conflict_id")
|
|
def step_create_bad_conflict(context: Any) -> None:
|
|
context.caught_exception = None
|
|
try:
|
|
SyncConflict(
|
|
conflict_id="",
|
|
entity_id="e1",
|
|
entity_type=SyncEntityType.ACTOR,
|
|
namespace="test",
|
|
local_snapshot=SyncEntitySnapshot(
|
|
entity_id="e1",
|
|
entity_type=SyncEntityType.ACTOR,
|
|
namespace="test",
|
|
name="test",
|
|
),
|
|
server_snapshot=SyncEntitySnapshot(
|
|
entity_id="e1",
|
|
entity_type=SyncEntityType.ACTOR,
|
|
namespace="test",
|
|
name="test",
|
|
),
|
|
local_clock=VectorClock(),
|
|
server_clock=VectorClock(),
|
|
)
|
|
except (PydanticValidationError, ValueError) as exc:
|
|
context.caught_exception = exc
|
|
|
|
|
|
@when("I try to create a queue entry with an empty queue_id")
|
|
def step_create_bad_queue_entry(context: Any) -> None:
|
|
context.caught_exception = None
|
|
try:
|
|
SyncQueueEntry(
|
|
queue_id="",
|
|
direction=SyncDirection.PUSH,
|
|
namespace="test",
|
|
entity_type=SyncEntityType.ACTOR,
|
|
entity_id="e1",
|
|
snapshot=SyncEntitySnapshot(
|
|
entity_id="e1",
|
|
entity_type=SyncEntityType.ACTOR,
|
|
namespace="test",
|
|
name="test",
|
|
),
|
|
)
|
|
except (PydanticValidationError, ValueError) as exc:
|
|
context.caught_exception = exc
|
|
|
|
|
|
@when("I try to create a sync state with an empty namespace")
|
|
def step_create_bad_sync_state(context: Any) -> None:
|
|
context.caught_exception = None
|
|
try:
|
|
SyncState(namespace="")
|
|
except (PydanticValidationError, ValueError) as exc:
|
|
context.caught_exception = exc
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# SyncService setup steps
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
@given('a sync service with node_id "{node_id}"')
|
|
def step_create_sync_service(context: Any, node_id: str) -> None:
|
|
context.sync_service = SyncService(node_id=node_id)
|
|
|
|
|
|
def _make_actor_snapshot(
|
|
entity_id: str,
|
|
namespace: str,
|
|
clock_entries: dict[str, int] | None = None,
|
|
updated_at: str = "",
|
|
) -> SyncEntitySnapshot:
|
|
"""Helper to create an actor snapshot."""
|
|
return SyncEntitySnapshot(
|
|
entity_id=entity_id,
|
|
entity_type=SyncEntityType.ACTOR,
|
|
namespace=namespace,
|
|
name=f"actor-{entity_id}",
|
|
data={"role": "executor"},
|
|
vector_clock=VectorClock(entries=clock_entries or {}),
|
|
updated_at=updated_at,
|
|
)
|
|
|
|
|
|
def _make_skill_snapshot(
|
|
entity_id: str,
|
|
namespace: str,
|
|
clock_entries: dict[str, int] | None = None,
|
|
) -> SyncEntitySnapshot:
|
|
"""Helper to create a skill snapshot."""
|
|
return SyncEntitySnapshot(
|
|
entity_id=entity_id,
|
|
entity_type=SyncEntityType.SKILL,
|
|
namespace=namespace,
|
|
name=f"skill-{entity_id}",
|
|
data={"category": "search"},
|
|
vector_clock=VectorClock(entries=clock_entries or {}),
|
|
)
|
|
|
|
|
|
@given('server entities in namespace "{namespace}" with {count:d} actors')
|
|
def step_setup_server_entities(context: Any, namespace: str, count: int) -> None:
|
|
svc: SyncService = context.sync_service
|
|
for i in range(count):
|
|
entity = _make_actor_snapshot(
|
|
entity_id=f"actor-{i}",
|
|
namespace=namespace,
|
|
clock_entries={"server": 1},
|
|
)
|
|
svc.register_server_entity(entity)
|
|
|
|
|
|
@given('a local actor "{name}" in namespace "{namespace}" with clock {clock_json}')
|
|
def step_setup_local_actor_with_clock(
|
|
context: Any, name: str, namespace: str, clock_json: str
|
|
) -> None:
|
|
entries = json.loads(clock_json)
|
|
entity = _make_actor_snapshot(
|
|
entity_id=name,
|
|
namespace=namespace,
|
|
clock_entries=entries,
|
|
)
|
|
context.sync_service.register_local_entity(entity)
|
|
context.local_entity = entity
|
|
|
|
|
|
@given(
|
|
'a local actor "{name}" in namespace "{namespace}" clocked {clock_json} at "{timestamp}"'
|
|
)
|
|
def step_setup_local_actor_with_clock_ts(
|
|
context: Any,
|
|
name: str,
|
|
namespace: str,
|
|
clock_json: str,
|
|
timestamp: str,
|
|
) -> None:
|
|
entries = json.loads(clock_json)
|
|
entity = _make_actor_snapshot(
|
|
entity_id=name,
|
|
namespace=namespace,
|
|
clock_entries=entries,
|
|
updated_at=timestamp,
|
|
)
|
|
context.sync_service.register_local_entity(entity)
|
|
context.local_entity = entity
|
|
|
|
|
|
@given('a server actor "{name}" in namespace "{namespace}" with clock {clock_json}')
|
|
def step_setup_server_actor_with_clock(
|
|
context: Any, name: str, namespace: str, clock_json: str
|
|
) -> None:
|
|
entries = json.loads(clock_json)
|
|
entity = _make_actor_snapshot(
|
|
entity_id=name,
|
|
namespace=namespace,
|
|
clock_entries=entries,
|
|
)
|
|
context.sync_service.register_server_entity(entity)
|
|
context.server_entity = entity
|
|
|
|
|
|
@given(
|
|
'a server actor "{name}" in namespace "{namespace}" clocked {clock_json} at "{timestamp}"'
|
|
)
|
|
def step_setup_server_actor_with_clock_ts(
|
|
context: Any,
|
|
name: str,
|
|
namespace: str,
|
|
clock_json: str,
|
|
timestamp: str,
|
|
) -> None:
|
|
entries = json.loads(clock_json)
|
|
entity = _make_actor_snapshot(
|
|
entity_id=name,
|
|
namespace=namespace,
|
|
clock_entries=entries,
|
|
updated_at=timestamp,
|
|
)
|
|
context.sync_service.register_server_entity(entity)
|
|
context.server_entity = entity
|
|
|
|
|
|
@given('server entities in namespace "{namespace}" with timestamps')
|
|
def step_setup_server_timestamped(context: Any, namespace: str) -> None:
|
|
svc: SyncService = context.sync_service
|
|
old = _make_actor_snapshot(
|
|
entity_id="old-actor",
|
|
namespace=namespace,
|
|
clock_entries={"server": 1},
|
|
updated_at="2024-01-01T00:00:00+00:00",
|
|
)
|
|
new = _make_actor_snapshot(
|
|
entity_id="new-actor",
|
|
namespace=namespace,
|
|
clock_entries={"server": 2},
|
|
updated_at="2025-06-01T00:00:00+00:00",
|
|
)
|
|
svc.register_server_entity(old)
|
|
svc.register_server_entity(new)
|
|
|
|
|
|
@given('{count:d} local actors in namespace "{namespace}"')
|
|
def step_setup_local_actors(context: Any, count: int, namespace: str) -> None:
|
|
context.local_push_entities = []
|
|
for i in range(count):
|
|
entity = _make_actor_snapshot(
|
|
entity_id=f"local-actor-{i}",
|
|
namespace=namespace,
|
|
clock_entries={"client": 1},
|
|
)
|
|
context.sync_service.register_local_entity(entity)
|
|
context.local_push_entities.append(entity)
|
|
|
|
|
|
@given('a local actor "{name}" in namespace "{namespace}" not on server')
|
|
def step_setup_local_only_actor(context: Any, name: str, namespace: str) -> None:
|
|
entity = _make_actor_snapshot(
|
|
entity_id=name,
|
|
namespace=namespace,
|
|
clock_entries={"client": 1},
|
|
)
|
|
context.sync_service.register_local_entity(entity)
|
|
|
|
|
|
@given('a local skill "{name}" in namespace "{namespace}" not on server')
|
|
def step_setup_local_only_skill(context: Any, name: str, namespace: str) -> None:
|
|
entity = _make_skill_snapshot(
|
|
entity_id=name,
|
|
namespace=namespace,
|
|
clock_entries={"client": 1},
|
|
)
|
|
context.sync_service.register_local_entity(entity)
|
|
|
|
|
|
@given('a server actor "{name}" in namespace "{namespace}" not on local')
|
|
def step_setup_server_only_actor(context: Any, name: str, namespace: str) -> None:
|
|
entity = _make_actor_snapshot(
|
|
entity_id=name,
|
|
namespace=namespace,
|
|
clock_entries={"server": 1},
|
|
)
|
|
context.sync_service.register_server_entity(entity)
|
|
|
|
|
|
@given('a synced actor "{name}" in namespace "{namespace}" on both sides')
|
|
def step_setup_synced_actor(context: Any, name: str, namespace: str) -> None:
|
|
entity = _make_actor_snapshot(
|
|
entity_id=name,
|
|
namespace=namespace,
|
|
clock_entries={"client": 1, "server": 1},
|
|
)
|
|
context.sync_service.register_local_entity(entity)
|
|
context.sync_service.register_server_entity(entity)
|
|
|
|
|
|
@given("an unresolved conflict between local and server versions")
|
|
def step_setup_unresolved_conflict(context: Any) -> None:
|
|
svc: SyncService = context.sync_service
|
|
local_entity = _make_actor_snapshot(
|
|
entity_id="conflict-ent",
|
|
namespace="team",
|
|
clock_entries={"client": 2, "server": 1},
|
|
updated_at="2025-06-01T00:00:00+00:00",
|
|
)
|
|
server_entity = _make_actor_snapshot(
|
|
entity_id="conflict-ent",
|
|
namespace="team",
|
|
clock_entries={"client": 1, "server": 2},
|
|
updated_at="2025-05-01T00:00:00+00:00",
|
|
)
|
|
svc.register_local_entity(local_entity)
|
|
svc.register_server_entity(server_entity)
|
|
# Trigger conflict via pull
|
|
pull_req = SyncPullRequest(namespace="team")
|
|
svc.pull(pull_req)
|
|
context.conflict_id = svc.conflicts[0].conflict_id
|
|
|
|
|
|
@given("an unresolved conflict where local is newer")
|
|
def step_setup_conflict_local_newer(context: Any) -> None:
|
|
svc: SyncService = context.sync_service
|
|
local_entity = _make_actor_snapshot(
|
|
entity_id="ts-conflict-ent",
|
|
namespace="team",
|
|
clock_entries={"client": 2, "server": 1},
|
|
updated_at="2025-07-01T00:00:00+00:00",
|
|
)
|
|
server_entity = _make_actor_snapshot(
|
|
entity_id="ts-conflict-ent",
|
|
namespace="team",
|
|
clock_entries={"client": 1, "server": 2},
|
|
updated_at="2025-01-01T00:00:00+00:00",
|
|
)
|
|
svc.register_local_entity(local_entity)
|
|
svc.register_server_entity(server_entity)
|
|
pull_req = SyncPullRequest(namespace="team")
|
|
svc.pull(pull_req)
|
|
context.conflict_id = svc.conflicts[0].conflict_id
|
|
|
|
|
|
@given("a local actor snapshot for offline queuing")
|
|
def step_setup_offline_snapshot(context: Any) -> None:
|
|
context.offline_entity = _make_actor_snapshot(
|
|
entity_id="offline-actor",
|
|
namespace="team",
|
|
clock_entries={"client": 1},
|
|
)
|
|
|
|
|
|
@given('a queued offline push for actor "{name}" in namespace "{namespace}"')
|
|
def step_setup_queued_push(context: Any, name: str, namespace: str) -> None:
|
|
entity = _make_actor_snapshot(
|
|
entity_id=name,
|
|
namespace=namespace,
|
|
clock_entries={"client": 1},
|
|
)
|
|
context.sync_service.enqueue_offline(
|
|
direction=SyncDirection.PUSH,
|
|
namespace=namespace,
|
|
entity=entity,
|
|
)
|
|
|
|
|
|
@given("a queued offline push at max retries")
|
|
def step_setup_max_retries(context: Any) -> None:
|
|
entity = _make_actor_snapshot(
|
|
entity_id="max-retry-actor",
|
|
namespace="team",
|
|
clock_entries={"client": 1},
|
|
)
|
|
entry = context.sync_service.enqueue_offline(
|
|
direction=SyncDirection.PUSH,
|
|
namespace="team",
|
|
entity=entity,
|
|
)
|
|
entry.retry_count = entry.max_retries
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Pull action steps
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
@when('I pull all entities from namespace "{namespace}"')
|
|
def step_pull_all(context: Any, namespace: str) -> None:
|
|
request = SyncPullRequest(namespace=namespace)
|
|
context.pull_response = context.sync_service.pull(request)
|
|
|
|
|
|
@when('I pull entities from namespace "{namespace}" since "{since}"')
|
|
def step_pull_since(context: Any, namespace: str, since: str) -> None:
|
|
request = SyncPullRequest(namespace=namespace, since=since)
|
|
context.pull_response = context.sync_service.pull(request)
|
|
|
|
|
|
@when('I force pull all entities from namespace "{namespace}"')
|
|
def step_force_pull(context: Any, namespace: str) -> None:
|
|
request = SyncPullRequest(namespace=namespace, force=True)
|
|
context.pull_response = context.sync_service.pull(request)
|
|
|
|
|
|
@when('I try to pull from namespace "{namespace}"')
|
|
def step_try_pull_local(context: Any, namespace: str) -> None:
|
|
context.caught_exception = None
|
|
try:
|
|
request = SyncPullRequest(namespace=namespace)
|
|
context.sync_service.pull(request)
|
|
except (ValidationError, PydanticValidationError) as exc:
|
|
context.caught_exception = exc
|
|
|
|
|
|
@when("I try to pull with a non-SyncPullRequest argument")
|
|
def step_pull_bad_type(context: Any) -> None:
|
|
context.caught_exception = None
|
|
try:
|
|
context.sync_service.pull("not a request") # type: ignore[arg-type]
|
|
except TypeError as exc:
|
|
context.caught_exception = exc
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Push action steps
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
@when('I push all local entities to namespace "{namespace}"')
|
|
def step_push_all(context: Any, namespace: str) -> None:
|
|
entities = getattr(context, "local_push_entities", [])
|
|
request = SyncPushRequest(namespace=namespace, entities=entities)
|
|
context.push_response = context.sync_service.push(request)
|
|
|
|
|
|
@when(
|
|
'I push the local actor to namespace "{namespace}" with last-writer-wins resolution'
|
|
)
|
|
def step_push_lww(context: Any, namespace: str) -> None:
|
|
request = SyncPushRequest(
|
|
namespace=namespace,
|
|
entities=[context.local_entity],
|
|
resolution=ConflictResolution.LAST_WRITER_WINS,
|
|
)
|
|
context.push_response = context.sync_service.push(request)
|
|
|
|
|
|
@when('I push the local actor to namespace "{namespace}" with server-wins resolution')
|
|
def step_push_server_wins(context: Any, namespace: str) -> None:
|
|
request = SyncPushRequest(
|
|
namespace=namespace,
|
|
entities=[context.local_entity],
|
|
resolution=ConflictResolution.SERVER_WINS,
|
|
)
|
|
context.push_response = context.sync_service.push(request)
|
|
|
|
|
|
@when('I push the local actor to namespace "{namespace}" with manual resolution')
|
|
def step_push_manual(context: Any, namespace: str) -> None:
|
|
request = SyncPushRequest(
|
|
namespace=namespace,
|
|
entities=[context.local_entity],
|
|
resolution=ConflictResolution.MANUAL,
|
|
)
|
|
context.push_response = context.sync_service.push(request)
|
|
|
|
|
|
@when('I force push a local actor "{name}" to namespace "{namespace}"')
|
|
def step_force_push(context: Any, name: str, namespace: str) -> None:
|
|
entity = _make_actor_snapshot(
|
|
entity_id=name,
|
|
namespace=namespace,
|
|
clock_entries={"client": 1},
|
|
)
|
|
request = SyncPushRequest(namespace=namespace, entities=[entity], force=True)
|
|
context.push_response = context.sync_service.push(request)
|
|
|
|
|
|
@when('I try to push to namespace "{namespace}"')
|
|
def step_try_push_local(context: Any, namespace: str) -> None:
|
|
context.caught_exception = None
|
|
try:
|
|
request = SyncPushRequest(namespace=namespace, entities=[])
|
|
context.sync_service.push(request)
|
|
except (ValidationError, PydanticValidationError) as exc:
|
|
context.caught_exception = exc
|
|
|
|
|
|
@when("I try to push with a non-SyncPushRequest argument")
|
|
def step_push_bad_type(context: Any) -> None:
|
|
context.caught_exception = None
|
|
try:
|
|
context.sync_service.push("not a request") # type: ignore[arg-type]
|
|
except TypeError as exc:
|
|
context.caught_exception = exc
|
|
|
|
|
|
@when('I push an empty entity list to namespace "{namespace}"')
|
|
def step_push_empty(context: Any, namespace: str) -> None:
|
|
request = SyncPushRequest(namespace=namespace, entities=[])
|
|
context.push_response = context.sync_service.push(request)
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Status action steps
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
@when('I request sync status for namespace "{namespace}"')
|
|
def step_check_status(context: Any, namespace: str) -> None:
|
|
request = SyncStatusRequest(namespace=namespace)
|
|
context.status_response = context.sync_service.status(request)
|
|
|
|
|
|
@when(
|
|
'I request filtered sync status for namespace "{namespace}" entity type "{entity_type}"'
|
|
)
|
|
def step_check_status_filtered(context: Any, namespace: str, entity_type: str) -> None:
|
|
et = SyncEntityType(entity_type)
|
|
request = SyncStatusRequest(namespace=namespace, entity_types=[et])
|
|
context.status_response = context.sync_service.status(request)
|
|
|
|
|
|
@when('I try requesting sync status for namespace "{namespace}"')
|
|
def step_try_status_local(context: Any, namespace: str) -> None:
|
|
context.caught_exception = None
|
|
try:
|
|
request = SyncStatusRequest(namespace=namespace)
|
|
context.sync_service.status(request)
|
|
except (ValidationError, PydanticValidationError) as exc:
|
|
context.caught_exception = exc
|
|
|
|
|
|
@when("I try to check status with a non-SyncStatusRequest argument")
|
|
def step_status_bad_type(context: Any) -> None:
|
|
context.caught_exception = None
|
|
try:
|
|
context.sync_service.status("not a request") # type: ignore[arg-type]
|
|
except TypeError as exc:
|
|
context.caught_exception = exc
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Conflict resolution steps
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
@when('I resolve the conflict manually choosing "{winner}"')
|
|
def step_resolve_manual(context: Any, winner: str) -> None:
|
|
svc: SyncService = context.sync_service
|
|
context.resolved_conflict = svc.resolve_conflict(
|
|
conflict_id=context.conflict_id,
|
|
resolution=ConflictResolution.MANUAL,
|
|
winner=winner,
|
|
)
|
|
|
|
|
|
@when("I try to resolve manually without specifying a winner")
|
|
def step_try_resolve_no_winner(context: Any) -> None:
|
|
context.caught_exception = None
|
|
try:
|
|
context.sync_service.resolve_conflict(
|
|
conflict_id=context.conflict_id,
|
|
resolution=ConflictResolution.MANUAL,
|
|
)
|
|
except BusinessRuleViolation as exc:
|
|
context.caught_exception = exc
|
|
|
|
|
|
@when('I try to resolve a conflict with id "{conflict_id}"')
|
|
def step_try_resolve_missing(context: Any, conflict_id: str) -> None:
|
|
context.caught_exception = None
|
|
try:
|
|
context.sync_service.resolve_conflict(
|
|
conflict_id=conflict_id,
|
|
resolution=ConflictResolution.LAST_WRITER_WINS,
|
|
)
|
|
except ValueError as exc:
|
|
context.caught_exception = exc
|
|
|
|
|
|
@when("I try to resolve a conflict with an empty id")
|
|
def step_try_resolve_empty_id(context: Any) -> None:
|
|
context.caught_exception = None
|
|
try:
|
|
context.sync_service.resolve_conflict(
|
|
conflict_id="",
|
|
resolution=ConflictResolution.LAST_WRITER_WINS,
|
|
)
|
|
except ValueError as exc:
|
|
context.caught_exception = exc
|
|
|
|
|
|
@when("I resolve the conflict with client_wins strategy")
|
|
def step_resolve_client_wins(context: Any) -> None:
|
|
context.resolved_conflict = context.sync_service.resolve_conflict(
|
|
conflict_id=context.conflict_id,
|
|
resolution=ConflictResolution.CLIENT_WINS,
|
|
)
|
|
|
|
|
|
@when("I resolve the conflict with server_wins strategy")
|
|
def step_resolve_server_wins(context: Any) -> None:
|
|
context.resolved_conflict = context.sync_service.resolve_conflict(
|
|
conflict_id=context.conflict_id,
|
|
resolution=ConflictResolution.SERVER_WINS,
|
|
)
|
|
|
|
|
|
@when("I resolve the conflict with last_writer_wins strategy")
|
|
def step_resolve_lww(context: Any) -> None:
|
|
context.resolved_conflict = context.sync_service.resolve_conflict(
|
|
conflict_id=context.conflict_id,
|
|
resolution=ConflictResolution.LAST_WRITER_WINS,
|
|
)
|
|
|
|
|
|
@when("I try to resolve with an invalid resolution type")
|
|
def step_resolve_bad_type(context: Any) -> None:
|
|
context.caught_exception = None
|
|
try:
|
|
context.sync_service.resolve_conflict(
|
|
conflict_id=context.conflict_id,
|
|
resolution="invalid", # type: ignore[arg-type]
|
|
)
|
|
except TypeError as exc:
|
|
context.caught_exception = exc
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Offline queue steps
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
@when('I enqueue an offline push to namespace "{namespace}"')
|
|
def step_enqueue_push(context: Any, namespace: str) -> None:
|
|
context.queue_entry = context.sync_service.enqueue_offline(
|
|
direction=SyncDirection.PUSH,
|
|
namespace=namespace,
|
|
entity=context.offline_entity,
|
|
)
|
|
|
|
|
|
@when('I enqueue an offline pull from namespace "{namespace}"')
|
|
def step_enqueue_pull(context: Any, namespace: str) -> None:
|
|
context.queue_entry = context.sync_service.enqueue_offline(
|
|
direction=SyncDirection.PULL,
|
|
namespace=namespace,
|
|
entity=context.offline_entity,
|
|
)
|
|
|
|
|
|
@when("I process the offline queue")
|
|
def step_process_queue(context: Any) -> None:
|
|
context.processed_entries = context.sync_service.process_offline_queue()
|
|
|
|
|
|
@when("I try to enqueue to an empty namespace")
|
|
def step_try_enqueue_empty_ns(context: Any) -> None:
|
|
context.caught_exception = None
|
|
try:
|
|
context.sync_service.enqueue_offline(
|
|
direction=SyncDirection.PUSH,
|
|
namespace="",
|
|
entity=context.offline_entity,
|
|
)
|
|
except ValueError as exc:
|
|
context.caught_exception = exc
|
|
|
|
|
|
@when('I try to enqueue to namespace "{namespace}"')
|
|
def step_try_enqueue_local(context: Any, namespace: str) -> None:
|
|
context.caught_exception = None
|
|
try:
|
|
context.sync_service.enqueue_offline(
|
|
direction=SyncDirection.PUSH,
|
|
namespace=namespace,
|
|
entity=context.offline_entity,
|
|
)
|
|
except ValueError as exc:
|
|
context.caught_exception = exc
|
|
|
|
|
|
@when("I try to enqueue with an invalid direction type")
|
|
def step_try_enqueue_bad_direction(context: Any) -> None:
|
|
context.caught_exception = None
|
|
try:
|
|
context.sync_service.enqueue_offline(
|
|
direction="invalid", # type: ignore[arg-type]
|
|
namespace="team",
|
|
entity=context.offline_entity,
|
|
)
|
|
except TypeError as exc:
|
|
context.caught_exception = exc
|
|
|
|
|
|
@when("I try to enqueue with an invalid entity type")
|
|
def step_try_enqueue_bad_entity(context: Any) -> None:
|
|
context.caught_exception = None
|
|
try:
|
|
context.sync_service.enqueue_offline(
|
|
direction=SyncDirection.PUSH,
|
|
namespace="team",
|
|
entity="not an entity", # type: ignore[arg-type]
|
|
)
|
|
except TypeError as exc:
|
|
context.caught_exception = exc
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Pull response assertions
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
@then("the pull response should contain {count:d} entities")
|
|
def step_pull_count(context: Any, count: int) -> None:
|
|
assert context.pull_response.total_pulled == count, (
|
|
f"Expected {count} pulled, got {context.pull_response.total_pulled}"
|
|
)
|
|
|
|
|
|
@then("the pull response should have {count:d} conflicts")
|
|
def step_pull_conflicts(context: Any, count: int) -> None:
|
|
assert len(context.pull_response.conflicts) == count, (
|
|
f"Expected {count} conflicts, got {len(context.pull_response.conflicts)}"
|
|
)
|
|
|
|
|
|
@then("only entities updated after the since timestamp are pulled")
|
|
def step_pull_only_newer(context: Any) -> None:
|
|
for entity in context.pull_response.entities:
|
|
if entity.updated_at:
|
|
assert entity.updated_at > "2025-01-01T00:00:00+00:00", (
|
|
f"Entity {entity.entity_id} updated_at {entity.updated_at} "
|
|
f"should be after the since timestamp"
|
|
)
|
|
|
|
|
|
@then("the pull response should have a sync state with last_pull_at set")
|
|
def step_pull_sync_state(context: Any) -> None:
|
|
assert context.pull_response.sync_state is not None
|
|
assert context.pull_response.sync_state.last_pull_at is not None
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Push response assertions
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
@then("the push response should have {count:d} accepted")
|
|
def step_push_accepted(context: Any, count: int) -> None:
|
|
assert context.push_response.total_accepted == count, (
|
|
f"Expected {count} accepted, got {context.push_response.total_accepted}"
|
|
)
|
|
|
|
|
|
@then("the push response should have {count:d} rejected")
|
|
def step_push_rejected(context: Any, count: int) -> None:
|
|
assert context.push_response.total_rejected == count, (
|
|
f"Expected {count} rejected, got {context.push_response.total_rejected}"
|
|
)
|
|
|
|
|
|
@then("the push response should have {count:d} conflicts")
|
|
def step_push_conflicts(context: Any, count: int) -> None:
|
|
assert len(context.push_response.conflicts) == count, (
|
|
f"Expected {count} conflicts, got {len(context.push_response.conflicts)}"
|
|
)
|
|
|
|
|
|
@then('the push conflict winner should be "{winner}"')
|
|
def step_push_conflict_winner(context: Any, winner: str) -> None:
|
|
assert len(context.push_response.conflicts) > 0, "No conflicts found"
|
|
assert context.push_response.conflicts[0].winner == winner, (
|
|
f"Expected winner '{winner}', got '{context.push_response.conflicts[0].winner}'"
|
|
)
|
|
|
|
|
|
@then("the push response should have a sync state with last_push_at set")
|
|
def step_push_sync_state(context: Any) -> None:
|
|
sync_state = context.push_response.model_dump().get("sync_state")
|
|
assert sync_state is not None
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Status response assertions
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
@then("the status should show drift detected")
|
|
def step_status_drift(context: Any) -> None:
|
|
assert context.status_response.drift_detected is True, (
|
|
"Expected drift_detected to be True"
|
|
)
|
|
|
|
|
|
@then("the status should show no drift")
|
|
def step_status_no_drift(context: Any) -> None:
|
|
assert context.status_response.drift_detected is False, (
|
|
"Expected drift_detected to be False"
|
|
)
|
|
|
|
|
|
@then("the status should have {count:d} local-ahead entities")
|
|
def step_status_local_ahead(context: Any, count: int) -> None:
|
|
assert len(context.status_response.local_ahead) == count, (
|
|
f"Expected {count} local-ahead, got {len(context.status_response.local_ahead)}"
|
|
)
|
|
|
|
|
|
@then("the status should have {count:d} server-ahead entities")
|
|
def step_status_server_ahead(context: Any, count: int) -> None:
|
|
assert len(context.status_response.server_ahead) == count, (
|
|
f"Expected {count} server-ahead, "
|
|
f"got {len(context.status_response.server_ahead)}"
|
|
)
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Conflict resolution assertions
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
@then("the conflict should be marked as resolved")
|
|
def step_conflict_resolved(context: Any) -> None:
|
|
assert context.resolved_conflict.resolved is True, (
|
|
"Expected conflict to be resolved"
|
|
)
|
|
|
|
|
|
@then('the conflict winner should be "{winner}"')
|
|
def step_conflict_winner(context: Any, winner: str) -> None:
|
|
assert context.resolved_conflict.winner == winner, (
|
|
f"Expected winner '{winner}', got '{context.resolved_conflict.winner}'"
|
|
)
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Offline queue assertions
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
@then("the offline queue should have {count:d} entry")
|
|
def step_queue_count(context: Any, count: int) -> None:
|
|
assert len(context.sync_service.offline_queue) == count, (
|
|
f"Expected {count} queue entries, got {len(context.sync_service.offline_queue)}"
|
|
)
|
|
|
|
|
|
@then('the queued entry should have status "{status}"')
|
|
def step_queue_status(context: Any, status: str) -> None:
|
|
assert context.queue_entry.status.value == status, (
|
|
f"Expected status '{status}', got '{context.queue_entry.status.value}'"
|
|
)
|
|
|
|
|
|
@then("the processed entries should include the offline actor")
|
|
def step_processed_includes_actor(context: Any) -> None:
|
|
assert len(context.processed_entries) > 0, "No entries processed"
|
|
|
|
|
|
@then("the offline queue should be empty")
|
|
def step_queue_empty(context: Any) -> None:
|
|
assert len(context.sync_service.offline_queue) == 0, (
|
|
f"Expected empty queue, got {len(context.sync_service.offline_queue)}"
|
|
)
|
|
|
|
|
|
@then('the failed entry should have status "failed"')
|
|
def step_failed_entry(context: Any) -> None:
|
|
failed = [
|
|
e for e in context.processed_entries if e.status == SyncOperationStatus.FAILED
|
|
]
|
|
assert len(failed) > 0, "Expected at least one failed entry"
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Facade integration steps
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
@given("a facade without sync service")
|
|
def step_facade_no_sync(context: Any) -> None:
|
|
context.facade = A2aLocalFacade()
|
|
|
|
|
|
@given("a facade with sync service containing server entities")
|
|
def step_facade_with_server_entities(context: Any) -> None:
|
|
svc = SyncService(node_id="facade-test")
|
|
entity = _make_actor_snapshot(
|
|
entity_id="facade-actor",
|
|
namespace="team",
|
|
clock_entries={"server": 1},
|
|
)
|
|
svc.register_server_entity(entity)
|
|
context.facade = A2aLocalFacade(services={"sync_service": svc})
|
|
|
|
|
|
@given("a facade with sync service")
|
|
def step_facade_with_sync(context: Any) -> None:
|
|
svc = SyncService(node_id="facade-test")
|
|
context.facade = A2aLocalFacade(services={"sync_service": svc})
|
|
|
|
|
|
@given("a facade with sync service containing local-only entities")
|
|
def step_facade_with_local_entities(context: Any) -> None:
|
|
svc = SyncService(node_id="facade-test")
|
|
entity = _make_actor_snapshot(
|
|
entity_id="local-only",
|
|
namespace="team",
|
|
clock_entries={"client": 1},
|
|
)
|
|
svc.register_local_entity(entity)
|
|
context.facade = A2aLocalFacade(services={"sync_service": svc})
|
|
|
|
|
|
@when('I dispatch "{operation}" with sync params {params_json}')
|
|
def step_dispatch_sync(context: Any, operation: str, params_json: str) -> None:
|
|
params = json.loads(params_json)
|
|
request = A2aRequest(operation=operation, params=params)
|
|
context.facade_response = context.facade.dispatch(request)
|
|
|
|
|
|
@when('I dispatch "{operation}" with sync push params')
|
|
def step_dispatch_push(context: Any, operation: str) -> None:
|
|
params = {
|
|
"namespace": "team",
|
|
"entities": [
|
|
{
|
|
"entity_id": "push-actor",
|
|
"entity_type": "actor",
|
|
"namespace": "team",
|
|
"name": "push-test",
|
|
},
|
|
],
|
|
}
|
|
request = A2aRequest(operation=operation, params=params)
|
|
context.facade_response = context.facade.dispatch(request)
|
|
|
|
|
|
@then("the facade response should be ok")
|
|
def step_facade_ok(context: Any) -> None:
|
|
assert context.facade_response.status == "ok", (
|
|
f"Expected 'ok', got '{context.facade_response.status}'"
|
|
)
|
|
|
|
|
|
@then('the facade response data should have "{key}" set to true')
|
|
def step_facade_stub_true(context: Any, key: str) -> None:
|
|
assert context.facade_response.data.get(key) is True, (
|
|
f"Expected data['{key}'] to be True, "
|
|
f"got {context.facade_response.data.get(key)}"
|
|
)
|
|
|
|
|
|
@then('the facade response data should contain key "{key}"')
|
|
def step_facade_has_key(context: Any, key: str) -> None:
|
|
assert key in context.facade_response.data, (
|
|
f"Expected key '{key}' in data, "
|
|
f"got: {list(context.facade_response.data.keys())}"
|
|
)
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Validation error with message
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Constructor validation steps
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
@when("I try to create a SyncService with a non-string node_id")
|
|
def step_create_svc_bad_nodeid(context: Any) -> None:
|
|
context.caught_exception = None
|
|
try:
|
|
SyncService(node_id=123) # type: ignore[arg-type]
|
|
except TypeError as exc:
|
|
context.caught_exception = exc
|
|
|
|
|
|
@when("I create a SyncService with None node_id")
|
|
def step_create_svc_none_nodeid(context: Any) -> None:
|
|
svc = SyncService(node_id=None)
|
|
context.created_service = svc
|
|
|
|
|
|
@then("the service should have a non-empty node_id")
|
|
def step_svc_has_nodeid(context: Any) -> None:
|
|
assert context.created_service.node_id, "Expected non-empty node_id"
|
|
|
|
|
|
@when("I try to register a non-snapshot as local entity")
|
|
def step_register_bad_local(context: Any) -> None:
|
|
context.caught_exception = None
|
|
try:
|
|
context.sync_service.register_local_entity("not an entity") # type: ignore[arg-type]
|
|
except TypeError as exc:
|
|
context.caught_exception = exc
|
|
|
|
|
|
@when("I try to register a non-snapshot as server entity")
|
|
def step_register_bad_server(context: Any) -> None:
|
|
context.caught_exception = None
|
|
try:
|
|
context.sync_service.register_server_entity("not an entity") # type: ignore[arg-type]
|
|
except TypeError as exc:
|
|
context.caught_exception = exc
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Entity-sync-specific assertion steps (avoids conflicts with global steps)
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
@then("a sync ValueError should be raised")
|
|
def step_sync_assert_value_error(context: Any) -> None:
|
|
assert isinstance(context.caught_exception, ValueError), (
|
|
f"Expected ValueError, got {type(context.caught_exception)}"
|
|
)
|
|
|
|
|
|
@then("a sync TypeError should be raised")
|
|
def step_sync_assert_type_error(context: Any) -> None:
|
|
assert isinstance(context.caught_exception, TypeError), (
|
|
f"Expected TypeError, got {type(context.caught_exception)}"
|
|
)
|
|
|
|
|
|
@then("a sync validation error should be raised")
|
|
def step_sync_assert_validation_error(context: Any) -> None:
|
|
assert isinstance(
|
|
context.caught_exception, (PydanticValidationError, ValueError)
|
|
), f"Expected validation error, got {type(context.caught_exception)}"
|
|
|
|
|
|
@then("a sync BusinessRuleViolation should be raised")
|
|
def step_sync_assert_business_rule(context: Any) -> None:
|
|
assert isinstance(context.caught_exception, BusinessRuleViolation), (
|
|
f"Expected BusinessRuleViolation, got {type(context.caught_exception)}"
|
|
)
|
|
|
|
|
|
@then('a sync ValidationError should be raised with message containing "{text}"')
|
|
def step_sync_validation_error_message(context: Any, text: str) -> None:
|
|
assert context.caught_exception is not None, "No exception was raised"
|
|
assert text in str(context.caught_exception), (
|
|
f"Expected '{text}' in error message, got: {context.caught_exception}"
|
|
)
|