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
- 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
453 lines
20 KiB
Gherkin
453 lines
20 KiB
Gherkin
@mock_only
|
|
Feature: Entity sync via _cleveragents/sync/*
|
|
As a CleverAgents user with multiple devices
|
|
I want to sync entities between my client and the server
|
|
So that I can access the same actors, skills, actions, plans, and sessions everywhere
|
|
|
|
# ------------------------------------------------------------------
|
|
# Sync models
|
|
# ------------------------------------------------------------------
|
|
|
|
Scenario: Vector clock increment produces new clock
|
|
Given a vector clock with entries {"client1": 1, "client2": 3}
|
|
When I increment the clock for node "client1"
|
|
Then the clock entry for "client1" should be 2
|
|
And the clock entry for "client2" should be 3
|
|
|
|
Scenario: Vector clock merge takes element-wise max
|
|
Given a vector clock with entries {"a": 2, "b": 1}
|
|
And another vector clock with entries {"a": 1, "b": 3, "c": 1}
|
|
When I merge the two clocks
|
|
Then the merged clock should have entries {"a": 2, "b": 3, "c": 1}
|
|
|
|
Scenario: Vector clock happens-before detection
|
|
Given a vector clock with entries {"a": 1, "b": 1}
|
|
And another vector clock with entries {"a": 2, "b": 2}
|
|
Then the first clock should happen before the second
|
|
|
|
Scenario: Vector clock concurrency detection
|
|
Given a vector clock with entries {"a": 2, "b": 1}
|
|
And another vector clock with entries {"a": 1, "b": 2}
|
|
Then the clocks should be concurrent
|
|
|
|
Scenario: Vector clock increment rejects empty node_id
|
|
Given an empty vector clock
|
|
When I try to increment the clock with an empty node_id
|
|
Then a sync ValueError should be raised
|
|
|
|
Scenario: Vector clock merge rejects non-VectorClock
|
|
Given an empty vector clock
|
|
When I try to merge with a non-VectorClock value
|
|
Then a sync TypeError should be raised
|
|
|
|
Scenario: SyncEntitySnapshot rejects empty entity_id
|
|
When I try to create a snapshot with an empty entity_id
|
|
Then a sync validation error should be raised
|
|
|
|
Scenario: SyncPullRequest rejects local namespace
|
|
When I try to create a pull request with namespace "local"
|
|
Then a sync validation error should be raised
|
|
|
|
Scenario: SyncPushRequest rejects local namespace
|
|
When I try to create a push request with namespace "local"
|
|
Then a sync validation error should be raised
|
|
|
|
Scenario: SyncStatusRequest rejects local namespace
|
|
When I try to create a status request with namespace "local"
|
|
Then a sync validation error should be raised
|
|
|
|
Scenario: SyncConflict requires non-empty conflict_id
|
|
When I try to create a conflict with an empty conflict_id
|
|
Then a sync validation error should be raised
|
|
|
|
Scenario: SyncQueueEntry requires non-empty queue_id
|
|
When I try to create a queue entry with an empty queue_id
|
|
Then a sync validation error should be raised
|
|
|
|
Scenario: SyncState requires non-empty namespace
|
|
When I try to create a sync state with an empty namespace
|
|
Then a sync validation error should be raised
|
|
|
|
# ------------------------------------------------------------------
|
|
# SyncService — pull
|
|
# ------------------------------------------------------------------
|
|
|
|
Scenario: Pull entities from server to local cache
|
|
Given a sync service with node_id "test-client"
|
|
And server entities in namespace "team" with 3 actors
|
|
When I pull all entities from namespace "team"
|
|
Then the pull response should contain 3 entities
|
|
And the pull response should have 0 conflicts
|
|
|
|
Scenario: Pull detects concurrent modifications as conflicts
|
|
Given a sync service with node_id "test-client"
|
|
And a local actor "my-actor" in namespace "team" with clock {"client": 2, "server": 1}
|
|
And a server actor "my-actor" in namespace "team" with clock {"client": 1, "server": 2}
|
|
When I pull all entities from namespace "team"
|
|
Then the pull response should have 1 conflicts
|
|
|
|
Scenario: Pull with incremental sync uses since parameter
|
|
Given a sync service with node_id "test-client"
|
|
And server entities in namespace "team" with timestamps
|
|
When I pull entities from namespace "team" since "2025-01-01T00:00:00+00:00"
|
|
Then only entities updated after the since timestamp are pulled
|
|
|
|
Scenario: Pull with force overwrites local entities
|
|
Given a sync service with node_id "test-client"
|
|
And a local actor "my-actor" in namespace "team" with clock {"client": 2}
|
|
And a server actor "my-actor" in namespace "team" with clock {"server": 1}
|
|
When I force pull all entities from namespace "team"
|
|
Then the pull response should contain 1 entities
|
|
And the pull response should have 0 conflicts
|
|
|
|
Scenario: Pull rejects local namespace
|
|
Given a sync service with node_id "test-client"
|
|
When I try to pull from namespace "local"
|
|
Then a sync ValidationError should be raised with message containing "local"
|
|
|
|
Scenario: Pull type-checks the request
|
|
Given a sync service with node_id "test-client"
|
|
When I try to pull with a non-SyncPullRequest argument
|
|
Then a sync TypeError should be raised
|
|
|
|
# ------------------------------------------------------------------
|
|
# SyncService — push
|
|
# ------------------------------------------------------------------
|
|
|
|
Scenario: Push entities from local to server
|
|
Given a sync service with node_id "test-client"
|
|
And 2 local actors in namespace "shared"
|
|
When I push all local entities to namespace "shared"
|
|
Then the push response should have 2 accepted
|
|
And the push response should have 0 rejected
|
|
|
|
Scenario: Push detects and resolves conflicts with last-writer-wins
|
|
Given a sync service with node_id "test-client"
|
|
And a local actor "conflict-actor" in namespace "team" clocked {"client": 2, "server": 1} at "2025-06-01T12:00:00+00:00"
|
|
And a server actor "conflict-actor" in namespace "team" clocked {"client": 1, "server": 2} at "2025-05-01T12:00:00+00:00"
|
|
When I push the local actor to namespace "team" with last-writer-wins resolution
|
|
Then the push response should have 1 accepted
|
|
And the push response should have 1 conflicts
|
|
|
|
Scenario: Push with force bypasses conflict detection
|
|
Given a sync service with node_id "test-client"
|
|
And a server actor "existing" in namespace "team" with clock {"server": 5}
|
|
When I force push a local actor "existing" to namespace "team"
|
|
Then the push response should have 1 accepted
|
|
And the push response should have 0 conflicts
|
|
|
|
Scenario: Push rejects local namespace
|
|
Given a sync service with node_id "test-client"
|
|
When I try to push to namespace "local"
|
|
Then a sync ValidationError should be raised with message containing "local"
|
|
|
|
Scenario: Push type-checks the request
|
|
Given a sync service with node_id "test-client"
|
|
When I try to push with a non-SyncPushRequest argument
|
|
Then a sync TypeError should be raised
|
|
|
|
# ------------------------------------------------------------------
|
|
# SyncService — status
|
|
# ------------------------------------------------------------------
|
|
|
|
Scenario: Status detects local-ahead drift
|
|
Given a sync service with node_id "test-client"
|
|
And a local actor "new-actor" in namespace "team" not on server
|
|
When I request sync status for namespace "team"
|
|
Then the status should show drift detected
|
|
And the status should have 1 local-ahead entities
|
|
|
|
Scenario: Status detects server-ahead drift
|
|
Given a sync service with node_id "test-client"
|
|
And a server actor "server-only" in namespace "team" not on local
|
|
When I request sync status for namespace "team"
|
|
Then the status should show drift detected
|
|
And the status should have 1 server-ahead entities
|
|
|
|
Scenario: Status detects no drift when in sync
|
|
Given a sync service with node_id "test-client"
|
|
And a synced actor "synced-actor" in namespace "team" on both sides
|
|
When I request sync status for namespace "team"
|
|
Then the status should show no drift
|
|
|
|
Scenario: Status rejects local namespace
|
|
Given a sync service with node_id "test-client"
|
|
When I try requesting sync status for namespace "local"
|
|
Then a sync ValidationError should be raised with message containing "local"
|
|
|
|
Scenario: Status type-checks the request
|
|
Given a sync service with node_id "test-client"
|
|
When I try to check status with a non-SyncStatusRequest argument
|
|
Then a sync TypeError should be raised
|
|
|
|
Scenario: Status filters by entity type
|
|
Given a sync service with node_id "test-client"
|
|
And a local actor "my-actor" in namespace "team" not on server
|
|
And a local skill "my-skill" in namespace "team" not on server
|
|
When I request filtered sync status for namespace "team" entity type "actor"
|
|
Then the status should have 1 local-ahead entities
|
|
|
|
# ------------------------------------------------------------------
|
|
# Conflict resolution
|
|
# ------------------------------------------------------------------
|
|
|
|
Scenario: Resolve conflict manually with local winner
|
|
Given a sync service with node_id "test-client"
|
|
And an unresolved conflict between local and server versions
|
|
When I resolve the conflict manually choosing "local"
|
|
Then the conflict should be marked as resolved
|
|
And the conflict winner should be "local"
|
|
|
|
Scenario: Resolve conflict manually with server winner
|
|
Given a sync service with node_id "test-client"
|
|
And an unresolved conflict between local and server versions
|
|
When I resolve the conflict manually choosing "server"
|
|
Then the conflict should be marked as resolved
|
|
And the conflict winner should be "server"
|
|
|
|
Scenario: Manual resolution without winner raises BusinessRuleViolation
|
|
Given a sync service with node_id "test-client"
|
|
And an unresolved conflict between local and server versions
|
|
When I try to resolve manually without specifying a winner
|
|
Then a sync BusinessRuleViolation should be raised
|
|
|
|
Scenario: Resolve non-existent conflict raises ValueError
|
|
Given a sync service with node_id "test-client"
|
|
When I try to resolve a conflict with id "nonexistent"
|
|
Then a sync ValueError should be raised
|
|
|
|
Scenario: Resolve conflict with empty conflict_id raises ValueError
|
|
Given a sync service with node_id "test-client"
|
|
When I try to resolve a conflict with an empty id
|
|
Then a sync ValueError should be raised
|
|
|
|
# ------------------------------------------------------------------
|
|
# Offline queue
|
|
# ------------------------------------------------------------------
|
|
|
|
Scenario: Enqueue offline push operation
|
|
Given a sync service with node_id "test-client"
|
|
And a local actor snapshot for offline queuing
|
|
When I enqueue an offline push to namespace "team"
|
|
Then the offline queue should have 1 entry
|
|
And the queued entry should have status "queued"
|
|
|
|
Scenario: Process offline queue retries push operations
|
|
Given a sync service with node_id "test-client"
|
|
And a queued offline push for actor "offline-actor" in namespace "team"
|
|
When I process the offline queue
|
|
Then the processed entries should include the offline actor
|
|
And the offline queue should be empty
|
|
|
|
Scenario: Offline queue respects max retries
|
|
Given a sync service with node_id "test-client"
|
|
And a queued offline push at max retries
|
|
When I process the offline queue
|
|
Then the failed entry should have status "failed"
|
|
|
|
Scenario: Enqueue rejects local namespace
|
|
Given a sync service with node_id "test-client"
|
|
And a local actor snapshot for offline queuing
|
|
When I try to enqueue to namespace "local"
|
|
Then a sync ValueError should be raised
|
|
|
|
Scenario: Enqueue rejects empty namespace
|
|
Given a sync service with node_id "test-client"
|
|
And a local actor snapshot for offline queuing
|
|
When I try to enqueue to an empty namespace
|
|
Then a sync ValueError should be raised
|
|
|
|
# ------------------------------------------------------------------
|
|
# Facade integration
|
|
# ------------------------------------------------------------------
|
|
|
|
Scenario: Facade sync/pull without service returns stub
|
|
Given a facade without sync service
|
|
When I dispatch "_cleveragents/sync/pull" with sync params {"namespace": "team"}
|
|
Then the facade response should be ok
|
|
And the facade response data should have "stub" set to true
|
|
|
|
Scenario: Facade sync/push without service returns stub
|
|
Given a facade without sync service
|
|
When I dispatch "_cleveragents/sync/push" with sync params {"namespace": "team", "entities": []}
|
|
Then the facade response should be ok
|
|
And the facade response data should have "stub" set to true
|
|
|
|
Scenario: Facade sync/status without service returns stub
|
|
Given a facade without sync service
|
|
When I dispatch "_cleveragents/sync/status" with sync params {"namespace": "team"}
|
|
Then the facade response should be ok
|
|
And the facade response data should have "stub" set to true
|
|
|
|
Scenario: Facade sync/pull with service returns entities
|
|
Given a facade with sync service containing server entities
|
|
When I dispatch "_cleveragents/sync/pull" with sync params {"namespace": "team"}
|
|
Then the facade response should be ok
|
|
And the facade response data should contain key "entities"
|
|
And the facade response data should contain key "total_pulled"
|
|
|
|
Scenario: Facade sync/push with service accepts entities
|
|
Given a facade with sync service
|
|
When I dispatch "_cleveragents/sync/push" with sync push params
|
|
Then the facade response should be ok
|
|
And the facade response data should contain key "accepted"
|
|
|
|
Scenario: Facade sync/status with service returns drift info
|
|
Given a facade with sync service containing local-only entities
|
|
When I dispatch "_cleveragents/sync/status" with sync params {"namespace": "team"}
|
|
Then the facade response should be ok
|
|
And the facade response data should contain key "drift_detected"
|
|
|
|
# ------------------------------------------------------------------
|
|
# SyncService constructor validation
|
|
# ------------------------------------------------------------------
|
|
|
|
Scenario: SyncService rejects non-string node_id
|
|
When I try to create a SyncService with a non-string node_id
|
|
Then a sync TypeError should be raised
|
|
|
|
Scenario: SyncService with None node_id generates ULID
|
|
When I create a SyncService with None node_id
|
|
Then the service should have a non-empty node_id
|
|
|
|
Scenario: SyncService register_local_entity rejects non-snapshot
|
|
Given a sync service with node_id "test-client"
|
|
When I try to register a non-snapshot as local entity
|
|
Then a sync TypeError should be raised
|
|
|
|
Scenario: SyncService register_server_entity rejects non-snapshot
|
|
Given a sync service with node_id "test-client"
|
|
When I try to register a non-snapshot as server entity
|
|
Then a sync TypeError should be raised
|
|
|
|
Scenario: Enqueue offline rejects invalid direction type
|
|
Given a sync service with node_id "test-client"
|
|
And a local actor snapshot for offline queuing
|
|
When I try to enqueue with an invalid direction type
|
|
Then a sync TypeError should be raised
|
|
|
|
Scenario: Enqueue offline rejects invalid entity type
|
|
Given a sync service with node_id "test-client"
|
|
When I try to enqueue with an invalid entity type
|
|
Then a sync TypeError should be raised
|
|
|
|
Scenario: Resolve conflict rejects invalid resolution type
|
|
Given a sync service with node_id "test-client"
|
|
And an unresolved conflict between local and server versions
|
|
When I try to resolve with an invalid resolution type
|
|
Then a sync TypeError should be raised
|
|
|
|
# ------------------------------------------------------------------
|
|
# Edge cases
|
|
# ------------------------------------------------------------------
|
|
|
|
Scenario: Pull from empty server returns empty response
|
|
Given a sync service with node_id "test-client"
|
|
When I pull all entities from namespace "empty-ns"
|
|
Then the pull response should contain 0 entities
|
|
|
|
Scenario: Push empty entity list returns empty response
|
|
Given a sync service with node_id "test-client"
|
|
When I push an empty entity list to namespace "team"
|
|
Then the push response should have 0 accepted
|
|
|
|
Scenario: Vector clock equal clocks are not concurrent
|
|
Given a vector clock with entries {"a": 1}
|
|
And another vector clock with entries {"a": 1}
|
|
Then the clocks should not be concurrent
|
|
|
|
Scenario: Status with entity type filter on skills
|
|
Given a sync service with node_id "test-client"
|
|
And a local skill "my-skill" in namespace "team" not on server
|
|
When I request filtered sync status for namespace "team" entity type "skill"
|
|
Then the status should have 1 local-ahead entities
|
|
|
|
Scenario: Push with server_wins resolution resolves to server entity
|
|
Given a sync service with node_id "test-client"
|
|
And a local actor "conflict-actor" in namespace "team" with clock {"client": 2, "server": 1}
|
|
And a server actor "conflict-actor" in namespace "team" with clock {"client": 1, "server": 2}
|
|
When I push the local actor to namespace "team" with server-wins resolution
|
|
Then the push response should have 1 accepted
|
|
And the push conflict winner should be "server"
|
|
|
|
Scenario: Push with manual resolution rejects conflicting entities
|
|
Given a sync service with node_id "test-client"
|
|
And a local actor "conflict-actor" in namespace "team" with clock {"client": 2, "server": 1}
|
|
And a server actor "conflict-actor" in namespace "team" with clock {"client": 1, "server": 2}
|
|
When I push the local actor to namespace "team" with manual resolution
|
|
Then the push response should have 0 accepted
|
|
And the push response should have 1 rejected
|
|
|
|
Scenario: Resolve conflict with client_wins
|
|
Given a sync service with node_id "test-client"
|
|
And an unresolved conflict between local and server versions
|
|
When I resolve the conflict with client_wins strategy
|
|
Then the conflict should be marked as resolved
|
|
And the conflict winner should be "local"
|
|
|
|
Scenario: Resolve conflict with server_wins
|
|
Given a sync service with node_id "test-client"
|
|
And an unresolved conflict between local and server versions
|
|
When I resolve the conflict with server_wins strategy
|
|
Then the conflict should be marked as resolved
|
|
And the conflict winner should be "server"
|
|
|
|
Scenario: Resolve conflict with last_writer_wins picks newer timestamp
|
|
Given a sync service with node_id "test-client"
|
|
And an unresolved conflict where local is newer
|
|
When I resolve the conflict with last_writer_wins strategy
|
|
Then the conflict should be marked as resolved
|
|
And the conflict winner should be "local"
|
|
|
|
Scenario: Pull updates sync state
|
|
Given a sync service with node_id "test-client"
|
|
And server entities in namespace "team" with 1 actors
|
|
When I pull all entities from namespace "team"
|
|
Then the pull response should have a sync state with last_pull_at set
|
|
|
|
Scenario: Push updates sync state
|
|
Given a sync service with node_id "test-client"
|
|
And 1 local actors in namespace "shared"
|
|
When I push all local entities to namespace "shared"
|
|
Then the push response should have a sync state with last_push_at set
|
|
|
|
Scenario: Offline queue enqueue pull operation
|
|
Given a sync service with node_id "test-client"
|
|
And a local actor snapshot for offline queuing
|
|
When I enqueue an offline pull from namespace "team"
|
|
Then the offline queue should have 1 entry
|
|
|
|
Scenario: Vector clock happens_before rejects non-VectorClock
|
|
Given an empty vector clock
|
|
When I try to call happens_before with a non-VectorClock value
|
|
Then a sync TypeError should be raised
|
|
|
|
Scenario: Vector clock is_concurrent rejects non-VectorClock
|
|
Given an empty vector clock
|
|
When I try to call is_concurrent with a non-VectorClock value
|
|
Then a sync TypeError should be raised
|
|
|
|
Scenario: Process offline queue processes pull operation
|
|
Given a sync service with node_id "test-client"
|
|
And server entities in namespace "team" with 1 actors
|
|
And a local actor snapshot for offline queuing
|
|
When I enqueue an offline pull from namespace "team"
|
|
And I process the offline queue
|
|
Then the processed entries should include the offline actor
|
|
And the offline queue should be empty
|
|
|
|
Scenario: Resolve conflict with last_writer_wins picks server when newer
|
|
Given a sync service with node_id "test-client"
|
|
And an unresolved conflict where server is newer
|
|
When I resolve the conflict with last_writer_wins strategy
|
|
Then the conflict should be marked as resolved
|
|
And the conflict winner should be "server"
|
|
|
|
Scenario: Push with client_wins resolution accepts local entity
|
|
Given a sync service with node_id "test-client"
|
|
And a local actor "conflict-actor" in namespace "team" with clock {"client": 2, "server": 1}
|
|
And a server actor "conflict-actor" in namespace "team" with clock {"client": 1, "server": 2}
|
|
When I push the local actor to namespace "team" with client-wins resolution
|
|
Then the push response should have 1 accepted
|
|
And the push conflict winner should be "local"
|