"""Tests for api/skill_sync.py. Covers task 3.7: create/update/remove sync scenarios, push-triggered immediate sync, query-time revocation re-check, and failure-preserves-cache. All tests use a FakeSubscriptionClient implementing the Protocol — no real HTTP traffic. """ from __future__ import annotations import json from typing import Any import pytest from api.skill_sync import ( HttpSubscriptionClient, SkillSyncRunner, SyncDelta, SyncOutcome, _parse_sync_payload, register_skill_sync_webhook, ) from skills_learning.models import ( FlowStep, FlowTemplateSkill, KnowledgeSkill, SkillMetadata, ) from storage.skill_catalog import SkillCatalogStore # ---------------------------------------------------------------------- # Test fakes and helpers # ---------------------------------------------------------------------- class FakeSubscriptionClient: """In-process implementation of the SubscriptionClient Protocol.""" def __init__(self) -> None: self.skills_by_sub: dict[str, list[Any]] = {} self.delta_overrides: dict[str, SyncDelta] = {} self.next_failure: Exception | None = None self.call_log: list[tuple[str, int | None]] = [] def set_entitled_skills(self, sub_id: str, skills: list[Any]) -> None: self.skills_by_sub[sub_id] = list(skills) def set_delta(self, sub_id: str, delta: SyncDelta) -> None: self.delta_overrides[sub_id] = delta def fail_next_with(self, exc: Exception) -> None: self.next_failure = exc def fetch_entitled_skills( self, subscription_id: str, since_version: int | None = None, ) -> SyncDelta: self.call_log.append((subscription_id, since_version)) if self.next_failure is not None: exc = self.next_failure self.next_failure = None raise exc if subscription_id in self.delta_overrides: return self.delta_overrides[subscription_id] skills = self.skills_by_sub.get(subscription_id, []) return SyncDelta( skills=list(skills), removed_ids=[], latest_version=1, is_full_replace=True, ) def _knowledge(skill_id: str, name: str, content: str) -> KnowledgeSkill: return KnowledgeSkill( metadata=SkillMetadata( id=skill_id, name=name, kind="knowledge", source="subscription", ), content=content, ) def _flow(skill_id: str, name: str, steps: list[tuple[str, dict]]) -> FlowTemplateSkill: return FlowTemplateSkill( metadata=SkillMetadata( id=skill_id, name=name, kind="flow_template", source="subscription", ), steps=[FlowStep(tool_name=t, args=a) for t, a in steps], ) @pytest.fixture def store(tmp_path): return SkillCatalogStore(db_path=tmp_path / "skills.sqlite3") @pytest.fixture def fake(): return FakeSubscriptionClient() @pytest.fixture def runner(store, fake): return SkillSyncRunner( store=store, client=fake, subscriptions=["sub-a"], poll_interval=0.01, ) # ---------------------------------------------------------------------- # Task 3.3 / 3.5: poll loop apply scenarios (create / update / remove) # ---------------------------------------------------------------------- def test_tick_creates_new_skills_in_catalog(store, fake, runner): fake.set_entitled_skills( "sub-a", [_knowledge("k1", "Knowledge", "body"), _flow("f1", "Flow", [("tap", {})])], ) outcomes = runner.tick() assert outcomes["sub-a"].success is True assert outcomes["sub-a"].fetched == 2 ids = {m.id for m in store.list_skills({"sub-a"})} assert ids == {"k1", "f1"} def test_tick_updates_existing_skill_content(store, fake, runner): fake.set_entitled_skills("sub-a", [_knowledge("k1", "K", "v1")]) runner.tick() fetched = store.get_skill("k1", {"sub-a"}) assert isinstance(fetched, KnowledgeSkill) assert fetched.content == "v1" # Sync again with new content fake.set_entitled_skills("sub-a", [_knowledge("k1", "K", "v2-updated")]) runner.tick() fetched = store.get_skill("k1", {"sub-a"}) assert isinstance(fetched, KnowledgeSkill) assert fetched.content == "v2-updated" def test_tick_removes_skills_no_longer_entitled(store, fake, runner): fake.set_entitled_skills( "sub-a", [_knowledge("k1", "K", "c"), _knowledge("k2", "K2", "c")], ) runner.tick() assert {m.id for m in store.list_skills({"sub-a"})} == {"k1", "k2"} # Full replace with only k1 → k2 must disappear fake.set_entitled_skills("sub-a", [_knowledge("k1", "K", "c")]) runner.tick() assert {m.id for m in store.list_skills({"sub-a"})} == {"k1"} def test_tick_applies_incremental_delta_via_upsert_and_remove(store, fake, runner): # Seed with full sync fake.set_entitled_skills( "sub-a", [_knowledge("k1", "K1", "c1"), _knowledge("k2", "K2", "c2")], ) runner.tick() # Now simulate an incremental delta: k1 content updated, k2 removed, k3 added fake.set_delta( "sub-a", SyncDelta( skills=[_knowledge("k1", "K1", "c1-updated"), _knowledge("k3", "K3", "c3")], removed_ids=["k2"], latest_version=5, is_full_replace=False, ), ) outcomes = runner.tick() assert outcomes["sub-a"].success is True assert outcomes["sub-a"].fetched == 2 ids = {m.id for m in store.list_skills({"sub-a"})} assert ids == {"k1", "k3"} fetched = store.get_skill("k1", {"sub-a"}) assert isinstance(fetched, KnowledgeSkill) assert fetched.content == "c1-updated" def test_tick_skips_unknown_subscriptions_in_outcome(store, fake): fake.set_entitled_skills("sub-a", [_knowledge("k1", "K", "c")]) runner = SkillSyncRunner( store=store, client=fake, subscriptions=["sub-a", "sub-b"], poll_interval=0.01, ) outcomes = runner.tick() # sub-b has no skills set → empty delta, success, fetched=0 assert outcomes["sub-b"].success is True assert outcomes["sub-b"].fetched == 0 assert {m.id for m in store.list_skills({"sub-a"})} == {"k1"} assert store.list_skills({"sub-b"}) == [] # ---------------------------------------------------------------------- # Task 3.6: sync failure preserves cache + records last_error # ---------------------------------------------------------------------- def test_tick_failure_preserves_cache_and_records_error(store, fake, runner): # Initial successful sync fake.set_entitled_skills("sub-a", [_knowledge("k1", "K", "c")]) runner.tick() assert store.get_skill("k1", {"sub-a"}) is not None # Next tick fails — cache must remain, error recorded fake.fail_next_with(RuntimeError("503 Service Unavailable")) outcomes = runner.tick() assert outcomes["sub-a"].success is False assert "503 Service Unavailable" in (outcomes["sub-a"].error or "") assert outcomes["sub-a"].fetched == 0 # Cache preserved fetched = store.get_skill("k1", {"sub-a"}) assert isinstance(fetched, KnowledgeSkill) assert fetched.content == "c" # last_error recorded in subscriptions table with store._connect() as conn: row = conn.execute( "SELECT last_error FROM subscriptions WHERE id = ?", ("sub-a",) ).fetchone() assert row is not None assert "503 Service Unavailable" in (row["last_error"] or "") def test_tick_failure_then_recovery_clears_error(store, fake, runner): fake.set_entitled_skills("sub-a", [_knowledge("k1", "K", "c")]) runner.tick() fake.fail_next_with(ConnectionError("network down")) runner.tick() # Successful sync after failure must clear last_error runner.tick() with store._connect() as conn: row = conn.execute( "SELECT last_error FROM subscriptions WHERE id = ?", ("sub-a",) ).fetchone() assert row["last_error"] is None # ---------------------------------------------------------------------- # Task 3.5: query-time revocation before next sync # ---------------------------------------------------------------------- def test_revocation_takes_effect_immediately_without_resync(store, fake, runner): """Marking a subscription inactive hides its skills on the next query, even if no further sync tick has run.""" fake.set_entitled_skills("sub-a", [_knowledge("k1", "K", "c")]) runner.tick() assert store.get_skill("k1", {"sub-a"}) is not None # Revoke sub-a without syncing again store._set_subscription_state("sub-a", active=False) # Query-time re-check (storage._effective_active) must hide the skill assert store.list_skills({"sub-a"}) == [] assert store.get_skill("k1", {"sub-a"}) is None # Union with another (still-active) subscription still excludes sub-a's skills store._register_subscription("sub-b") assert store.list_skills({"sub-a", "sub-b"}) == [] # ---------------------------------------------------------------------- # Task 3.4: push-triggered (webhook) immediate sync # ---------------------------------------------------------------------- def test_webhook_triggers_immediate_sync_of_all_subscriptions(store, fake): from fastapi import FastAPI from fastapi.testclient import TestClient fake.set_entitled_skills("sub-a", [_knowledge("k1", "K", "c")]) runner = SkillSyncRunner( store=store, client=fake, subscriptions=["sub-a"], poll_interval=999.0, # never auto-poll ) app = FastAPI() register_skill_sync_webhook(app, runner) with TestClient(app) as client: response = client.post("/webhooks/skill-sync") assert response.status_code == 200 body = response.json() assert body["ok"] is True assert body["outcomes"]["sub-a"]["success"] is True # Catalog now reflects the synced skill without any tick() call from us assert {m.id for m in store.list_skills({"sub-a"})} == {"k1"} def test_webhook_with_subscription_id_syncs_only_that_subscription(store, fake): from fastapi import FastAPI from fastapi.testclient import TestClient fake.set_entitled_skills("sub-a", [_knowledge("k1", "K", "c")]) fake.set_entitled_skills("sub-b", [_knowledge("k2", "K2", "c")]) runner = SkillSyncRunner( store=store, client=fake, subscriptions=["sub-a", "sub-b"], poll_interval=999.0, ) app = FastAPI() register_skill_sync_webhook(app, runner) with TestClient(app) as client: response = client.post( "/webhooks/skill-sync", json={"subscription_id": "sub-a"} ) assert response.status_code == 200 body = response.json() assert set(body["outcomes"].keys()) == {"sub-a"} # Only sub-a was synced assert {m.id for m in store.list_skills({"sub-a"})} == {"k1"} # sub-b was not synced in this call assert store.list_skills({"sub-b"}) == [] def test_register_webhook_rejects_non_fastapi_app(): runner = object() # not a SkillSyncRunner, not a FastAPI app with pytest.raises(TypeError): register_skill_sync_webhook("not-an-app", runner) # type: ignore[arg-type] # ---------------------------------------------------------------------- # Background poll loop # ---------------------------------------------------------------------- def test_start_background_polls_until_stopped(store, fake): call_count = {"n": 0} class CountingClient(FakeSubscriptionClient): def fetch_entitled_skills(self, subscription_id, since_version=None): call_count["n"] += 1 return super().fetch_entitled_skills(subscription_id, since_version) client = CountingClient() client.set_entitled_skills("sub-a", [_knowledge("k1", "K", "c")]) runner = SkillSyncRunner( store=store, client=client, subscriptions=["sub-a"], poll_interval=0.01, ) runner.start_background() try: # Allow at least 2 polls import time time.sleep(0.05) finally: runner.stop_background() assert call_count["n"] >= 2 # ---------------------------------------------------------------------- # HttpSubscriptionClient: payload parsing + httpx transport (mocked) # ---------------------------------------------------------------------- def test_parse_sync_payload_handles_knowledge_and_flow(): payload = { "skills": [ {"id": "k1", "name": "K", "kind": "knowledge", "content": "body"}, { "id": "f1", "name": "F", "kind": "flow_template", "steps": [{"tool_name": "tap", "args": {"x": 1}}], "parameters": {"x": {"type": "int"}}, }, ], "removed_ids": ["old1"], "latest_version": 7, "is_full_replace": False, } delta = _parse_sync_payload(payload) assert len(delta.skills) == 2 assert isinstance(delta.skills[0], KnowledgeSkill) assert isinstance(delta.skills[1], FlowTemplateSkill) assert delta.skills[1].steps[0].tool_name == "tap" assert delta.removed_ids == ["old1"] assert delta.latest_version == 7 assert delta.is_full_replace is False def test_parse_sync_payload_defaults_to_full_replace(): delta = _parse_sync_payload({"skills": []}) assert delta.skills == [] assert delta.removed_ids == [] assert delta.latest_version is None assert delta.is_full_replace is True def test_http_client_uses_injected_httpx_client_and_parses_response(monkeypatch): captured: dict[str, Any] = {} class FakeResponse: def raise_for_status(self) -> None: return None def json(self) -> dict[str, Any]: return {"skills": [{"id": "k1", "name": "K", "kind": "knowledge"}]} class FakeHttpxClient: def get(self, url, *, params=None, headers=None, timeout=None): captured["url"] = url captured["params"] = params captured["headers"] = headers captured["timeout"] = timeout return FakeResponse() def close(self) -> None: captured["closed"] = True fake_http = FakeHttpxClient() client = HttpSubscriptionClient( base_url="https://subscription.example.com/", auth_token="secret", client=fake_http, ) delta = client.fetch_entitled_skills("sub-a") assert captured["url"] == "https://subscription.example.com/subscriptions/sub-a/skills" assert captured["headers"] == {"Authorization": "Bearer secret"} assert len(delta.skills) == 1 assert isinstance(delta.skills[0], KnowledgeSkill) client.close() assert captured.get("closed") is True def test_http_client_raises_on_status_error_propagates_to_runner(store): """HTTP errors surface as a failed SyncOutcome, not a raised exception.""" class BoomResponse: def raise_for_status(self): raise RuntimeError("simulated 500") class FailingClient: def get(self, url, *, params=None, headers=None, timeout=None): return BoomResponse() def close(self): pass http_client = HttpSubscriptionClient( base_url="https://subscription.example.com", client=FailingClient(), ) runner = SkillSyncRunner( store=store, client=http_client, subscriptions=["sub-a"], ) outcomes = runner.tick() assert outcomes["sub-a"].success is False assert "simulated 500" in (outcomes["sub-a"].error or "")