Adds CloudApiSkillClient (Cloud API per-host sync endpoint + inventory report), forwards since_version for incremental sync (full-replace on first/stale), and forks a local override into a standalone local skill when its cloud skill is revoked (design D9). Skill-side tests green (87 passed). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
138 lines
4.5 KiB
Python
138 lines
4.5 KiB
Python
"""Tests for the Cloud API skill sync client and runner enhancements:
|
|
incremental since_version forwarding and fork-on-revocation (design D3/D9).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from api.skill_sync import (
|
|
CloudApiSkillClient,
|
|
SkillSyncRunner,
|
|
SyncDelta,
|
|
)
|
|
from skills_learning.models import KnowledgeSkill, SkillMetadata
|
|
from storage.local_skills import LocalSkillStore
|
|
from storage.skill_catalog import SkillCatalogStore
|
|
|
|
|
|
def _cloud_skill_dict(skill_id: str, name: str, content: str) -> dict:
|
|
return {
|
|
"id": skill_id,
|
|
"name": name,
|
|
"kind": "knowledge",
|
|
"description": "d",
|
|
"tags": [],
|
|
"revision": 3,
|
|
"created_at": "2026-01-01T00:00:00+00:00",
|
|
"updated_at": "2026-01-01T00:00:00+00:00",
|
|
"content": content,
|
|
"steps": [],
|
|
"parameters": {},
|
|
}
|
|
|
|
|
|
def test_cloud_api_skill_client_parses_revision_to_version():
|
|
captured: dict = {}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
captured["since"] = request.url.params.get("since_version")
|
|
captured["auth"] = request.headers.get("authorization")
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"skills": [_cloud_skill_dict("c1", "Cloud One", "body")],
|
|
"removed_ids": [],
|
|
"latest_version": 7,
|
|
"is_full_replace": False,
|
|
},
|
|
)
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
client = CloudApiSkillClient(
|
|
"https://cloud.example/",
|
|
host_token="host-token",
|
|
client=httpx.Client(transport=transport),
|
|
)
|
|
delta = client.fetch_entitled_skills("host-1", since_version=5)
|
|
assert captured["since"] == "5"
|
|
assert captured["auth"] == "Bearer host-token"
|
|
assert delta.latest_version == 7
|
|
assert delta.is_full_replace is False
|
|
[skill] = delta.skills
|
|
assert skill.id == "c1"
|
|
assert skill.metadata.version == 3 # revision mapped to version
|
|
assert isinstance(skill, KnowledgeSkill)
|
|
assert skill.content == "body"
|
|
|
|
|
|
class _RecordingClient:
|
|
def __init__(self, deltas: list[SyncDelta]) -> None:
|
|
self._deltas = list(deltas)
|
|
self.seen_since: list[int | None] = []
|
|
|
|
def fetch_entitled_skills(self, subscription_id, since_version=None):
|
|
self.seen_since.append(since_version)
|
|
return self._deltas.pop(0)
|
|
|
|
|
|
def test_runner_forwards_since_version_after_first_full_replace(tmp_path):
|
|
store = SkillCatalogStore(db_path=tmp_path / "skills.sqlite3")
|
|
deltas = [
|
|
SyncDelta(
|
|
skills=[KnowledgeSkill(metadata=SkillMetadata(id="c1", name="A"), content="x")],
|
|
removed_ids=[],
|
|
latest_version=1,
|
|
is_full_replace=True,
|
|
),
|
|
SyncDelta(
|
|
skills=[],
|
|
removed_ids=[],
|
|
latest_version=1,
|
|
is_full_replace=False,
|
|
),
|
|
]
|
|
client = _RecordingClient(deltas)
|
|
runner = SkillSyncRunner(
|
|
store=store, client=client, subscriptions=["host-1"], poll_interval=999.0
|
|
)
|
|
runner.tick()
|
|
assert client.seen_since[0] is None # first sync is full
|
|
runner.tick()
|
|
assert client.seen_since[1] == 1 # second forwards last version
|
|
|
|
|
|
def test_runner_forks_override_on_revocation(tmp_path):
|
|
store = SkillCatalogStore(db_path=tmp_path / "skills.sqlite3")
|
|
local = LocalSkillStore(db_path=tmp_path / "local.sqlite3")
|
|
|
|
# Seed the synced store with a cloud skill and a local override for it.
|
|
store._register_subscription("host-1")
|
|
cloud_skill = KnowledgeSkill(
|
|
metadata=SkillMetadata(id="c-cloud", name="Cloud", source="cloud"),
|
|
content="original",
|
|
)
|
|
store._apply_sync_upsert(cloud_skill, "host-1")
|
|
local.upsert_override("c-cloud", KnowledgeSkill(
|
|
metadata=SkillMetadata(id="c-cloud", name="Cloud"),
|
|
content="my override",
|
|
))
|
|
store._set_subscription_state("host-1", last_synced_version=1)
|
|
|
|
client = _RecordingClient([
|
|
SyncDelta(skills=[], removed_ids=["c-cloud"], latest_version=2, is_full_replace=False)
|
|
])
|
|
runner = SkillSyncRunner(
|
|
store=store, client=client, subscriptions=["host-1"],
|
|
poll_interval=999.0, local_store=local,
|
|
)
|
|
runner.tick()
|
|
|
|
# Override forked into a standalone local skill.
|
|
overrides_after = local.list_overrides()
|
|
assert overrides_after == []
|
|
forked = [s for s in local.list_local() if s.name == "Cloud"]
|
|
assert len(forked) == 1
|
|
# Cloud skill removed from the synced store.
|
|
assert store.get_skill("c-cloud", {"host-1"}) is None
|