"""Repository + service tests for Cloud-managed skills and per-host sync. Uses an in-memory SQLite engine. Covers skill CRUD, per-host entitlement, the monotonic entitlement_version bump, and incremental vs full-replace fetch_host_delta semantics (design D2/D3). """ from __future__ import annotations import json import pytest from sqlalchemy import create_engine from sqlalchemy.pool import StaticPool from cloud.db_models import Base from cloud.skills import ( CloudSkillConflictError, CloudSkillValidationError, CloudSkillService, ) from cloud.sql_repository import SQLAlchemyCloudRepository from core.models import utc_now @pytest.fixture def service(): engine = create_engine( "sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool, ) Base.metadata.create_all(engine) repo = SQLAlchemyCloudRepository(engine=engine, create_schema=False) return CloudSkillService(repo) def _knowledge_payload(name: str, content: str = "body") -> dict: return dict( name=name, kind="knowledge", description="d", tags=["t"], content=content, steps_json="[]", parameters_json="{}", ) def test_create_list_get_skill(service): now = utc_now() created = service.create_skill(now=now, **_knowledge_payload("Alpha")) assert created.name == "Alpha" assert created.kind == "knowledge" [fetched] = service.list_skills() assert fetched.id == created.id assert service.get_skill(created.id).content == "body" def test_create_rejects_duplicate_name(service): service.create_skill(now=utc_now(), **_knowledge_payload("Alpha")) with pytest.raises(CloudSkillConflictError): service.create_skill(now=utc_now(), **_knowledge_payload("Alpha")) def test_create_rejects_blank_content_for_knowledge(service): payload = _knowledge_payload("Alpha", content=" ") with pytest.raises(CloudSkillValidationError): service.create_skill(now=utc_now(), **payload) def test_grant_revoke_entitlement_drives_delta(service): now = utc_now() skill = service.create_skill(now=now, **_knowledge_payload("Alpha")) host = "host-1" # First sync: no entitlements yet -> empty full replace. delta = service.fetch_host_delta(host, since_version=None) assert delta.is_full_replace is True assert delta.skills == [] assert delta.latest_version == 0 # Grant -> version bumps, next full sync sees the skill. service.grant_entitlement(skill.id, host, now=now) full = service.fetch_host_delta(host, since_version=None) assert [s.id for s in full.skills] == [skill.id] assert full.latest_version == 1 # Incremental from 0 returns the grant. incr = service.fetch_host_delta(host, since_version=0) assert incr.is_full_replace is False assert [s.id for s in incr.skills] == [skill.id] assert incr.removed_ids == [] # Revoke -> version bumps, incremental reports removal. service.revoke_entitlement(skill.id, host, now=now) after = service.fetch_host_delta(host, since_version=incr.latest_version) assert after.removed_ids == [skill.id] assert after.skills == [] def test_skill_content_update_notifies_entitled_hosts(service): now = utc_now() skill = service.create_skill(now=now, **_knowledge_payload("Alpha", "v1")) service.grant_entitlement(skill.id, "host-1", now=now) baseline = service.fetch_host_delta("host-1", since_version=None).latest_version updated = service.update_skill( skill.id, now=utc_now(), **_knowledge_payload("Alpha", "v2") ) assert updated.revision == 2 incr = service.fetch_host_delta("host-1", since_version=baseline) assert [s.id for s in incr.skills] == [skill.id] assert incr.skills[0].content == "v2" def test_delete_skill_removes_and_notifies_entitled_hosts(service): now = utc_now() skill = service.create_skill(now=now, **_knowledge_payload("Alpha")) service.grant_entitlement(skill.id, "host-1", now=now) baseline = service.fetch_host_delta("host-1", since_version=None).latest_version service.delete_skill(skill.id) assert service.get_skill(skill.id) is None after = service.fetch_host_delta("host-1", since_version=baseline) assert after.removed_ids == [skill.id] def test_stale_since_version_falls_back_to_full_replace(service): now = utc_now() skill = service.create_skill(now=now, **_knowledge_payload("Alpha")) service.grant_entitlement(skill.id, "host-1", now=now) # A version older than anything in the changelog must yield a full replace. delta = service.fetch_host_delta("host-1", since_version=-5) assert delta.is_full_replace is True def test_inventory_record_and_readback(service): now = utc_now() payload = json.dumps([{"id": "local-1", "name": "My Note", "origin": "local"}]) service.record_host_inventory("host-1", payload, now=now) entry = service.get_host_inventory("host-1") assert entry is not None assert json.loads(entry.payload_json)[0]["name"] == "My Note"