Tests / Test passed: 819
Adds cloud/skills.py (domain + service), SQLAlchemy models and Alembic migration 0010_skill_management (cloud_skills, cloud_skill_entitlements, cloud_skill_sync_state, a per-host changelog, and host_skill_inventory), and repository methods with a monotonic per-host entitlement_version that drives correct incremental fetch_host_delta. cloud-api suite green (41 passed); HEAD_REVISION bumped to 0010. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
144 lines
5.0 KiB
Python
144 lines
5.0 KiB
Python
"""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"
|