Files
cleveragents-core/features/steps/entity_sync_steps.py
T
HAL9000 c77f90b4ff
CI / push-validation (pull_request) Successful in 26s
CI / helm (pull_request) Successful in 28s
CI / lint (pull_request) Successful in 46s
CI / quality (pull_request) Successful in 47s
CI / build (pull_request) Successful in 48s
CI / typecheck (pull_request) Successful in 1m8s
CI / security (pull_request) Successful in 1m17s
CI / integration_tests (pull_request) Failing after 2m54s
CI / unit_tests (pull_request) Failing after 4m48s
CI / coverage (pull_request) Has been skipped
CI / docker (pull_request) Has been skipped
CI / status-check (pull_request) Failing after 3s
fix(sync): add missing coverage scenarios and remove dead _iso_now from sync_models
- Remove unused _iso_now() from sync_models.py (dead code: called nowhere in
  the module; sync_service.py has its own _iso_now())
- Remove now-unused `from datetime import UTC, datetime` import
- Add 5 new BDD scenarios covering previously uncovered code paths:
  - VectorClock.happens_before() TypeError guard (sync_models.py:134-135)
  - VectorClock.is_concurrent() TypeError guard (sync_models.py:159-160)
  - process_offline_queue() PULL direction branch (sync_service.py:468-475)
  - resolve_conflict() last_writer_wins when server entity is newer (sync_service.py:549 else)
  - SyncService.push() with CLIENT_WINS resolution (_resolve_conflict lines 663-665)
- Add corresponding step implementations for the new scenarios

ISSUES CLOSED: #1125
2026-05-29 07:21:08 -04:00

1226 lines
41 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
@when("I try to call happens_before with a non-VectorClock value")
def step_happens_before_bad_type(context: Any) -> None:
context.caught_exception = None
try:
context.clock1.happens_before("not a clock") # type: ignore[arg-type]
except TypeError as exc:
context.caught_exception = exc
@when("I try to call is_concurrent with a non-VectorClock value")
def step_is_concurrent_bad_type(context: Any) -> None:
context.caught_exception = None
try:
context.clock1.is_concurrent("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 server is newer")
def step_setup_conflict_server_newer(context: Any) -> None:
svc: SyncService = context.sync_service
local_entity = _make_actor_snapshot(
entity_id="ts-conflict-srv",
namespace="team",
clock_entries={"client": 2, "server": 1},
updated_at="2025-01-01T00:00:00+00:00",
)
server_entity = _make_actor_snapshot(
entity_id="ts-conflict-srv",
namespace="team",
clock_entries={"client": 1, "server": 2},
updated_at="2025-07-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("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 client-wins resolution')
def step_push_client_wins(context: Any, namespace: str) -> None:
request = SyncPushRequest(
namespace=namespace,
entities=[context.local_entity],
resolution=ConflictResolution.CLIENT_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(method=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(method=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.error is None, (
f"Expected no error, got {context.facade_response.error}"
)
assert context.facade_response.result is not None, "Expected result to be set"
@then('the facade response data should have "{key}" set to true')
def step_facade_stub_true(context: Any, key: str) -> None:
result = context.facade_response.result or {}
assert result.get(key) is True, (
f"Expected result['{key}'] to be True, got {result.get(key)}"
)
@then('the facade response data should contain key "{key}"')
def step_facade_has_key(context: Any, key: str) -> None:
result = context.facade_response.result or {}
assert key in result, f"Expected key '{key}' in result, got: {list(result.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}"
)