Co-authored-by: Jean Burellier <sheplu@users.noreply.github.com> Co-authored-by: Kim-Adeline Miguel <51720070+kimadeline@users.noreply.github.com> Co-authored-by: Mathias Gesbert <mathias.gesbert@mistral.ai> Co-authored-by: Nelson PROIA <144663685+Nelson-PROIA@users.noreply.github.com> Co-authored-by: renovate-mistral[bot] <253709520+renovate-mistral[bot]@users.noreply.github.com> Co-authored-by: Mistral Vibe <vibe@mistral.ai>
195 lines
7 KiB
Python
195 lines
7 KiB
Python
from __future__ import annotations
|
|
|
|
import httpx
|
|
import pytest
|
|
import respx
|
|
|
|
from vibe.core.skills.registry._client import RegistrySkillsClient, RegistrySkillsError
|
|
|
|
_URL = "https://api.mistral.ai/v1/skills"
|
|
|
|
|
|
def _page(skill_id: str, *, next_token: str = "") -> dict[str, object]:
|
|
return {
|
|
"data": [
|
|
{"skillId": skill_id, "skill": {"skillName": skill_id, "skillBody": "b"}}
|
|
],
|
|
"nextPageToken": next_token,
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@respx.mock
|
|
async def test_list_catalog_paginates() -> None:
|
|
route = respx.get(_URL)
|
|
route.side_effect = [
|
|
httpx.Response(200, json=_page("a", next_token="p2")),
|
|
httpx.Response(200, json=_page("b")),
|
|
]
|
|
|
|
async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client:
|
|
items = await client.list_catalog(page_size=50)
|
|
|
|
assert [item.skill_id for item in items] == ["a", "b"]
|
|
assert route.calls[0].request.url.params["pageSize"] == "50"
|
|
assert "pageToken" not in route.calls[0].request.url.params
|
|
assert route.calls[1].request.url.params["pageToken"] == "p2"
|
|
assert route.calls[0].request.headers["Authorization"] == "Bearer key"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@respx.mock
|
|
async def test_list_catalog_single_page() -> None:
|
|
respx.get(_URL).mock(return_value=httpx.Response(200, json=_page("only")))
|
|
async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client:
|
|
items = await client.list_catalog(page_size=100)
|
|
assert [item.skill_id for item in items] == ["only"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@respx.mock
|
|
async def test_unauthorized_raises() -> None:
|
|
respx.get(_URL).mock(return_value=httpx.Response(401, json={"message": "no"}))
|
|
async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client:
|
|
with pytest.raises(RegistrySkillsError, match="unauthorized"):
|
|
await client.list_catalog(page_size=10)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@respx.mock
|
|
async def test_server_error_raises() -> None:
|
|
respx.get(_URL).mock(return_value=httpx.Response(503))
|
|
async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client:
|
|
with pytest.raises(RegistrySkillsError, match="unexpected status"):
|
|
await client.list_catalog(page_size=10)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@respx.mock
|
|
async def test_non_json_raises() -> None:
|
|
respx.get(_URL).mock(return_value=httpx.Response(200, text="not json"))
|
|
async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client:
|
|
with pytest.raises(RegistrySkillsError, match="valid JSON"):
|
|
await client.list_catalog(page_size=10)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@respx.mock
|
|
async def test_network_error_raises() -> None:
|
|
respx.get(_URL).mock(side_effect=httpx.ConnectError("boom"))
|
|
async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client:
|
|
with pytest.raises(RegistrySkillsError, match="request failed"):
|
|
await client.list_catalog(page_size=10)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@respx.mock
|
|
async def test_list_catalog_sends_fields_mask() -> None:
|
|
route = respx.get(_URL).mock(return_value=httpx.Response(200, json=_page("a")))
|
|
async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client:
|
|
await client.list_catalog(page_size=10)
|
|
params = route.calls[0].request.url.params
|
|
assert params["pageSize"] == "10"
|
|
assert "skillBody" not in params["fields"]
|
|
assert "skillName" in params["fields"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@respx.mock
|
|
async def test_list_versions_parses_aliases_and_sorts_desc() -> None:
|
|
respx.get(f"{_URL}/sid/versions").mock(
|
|
return_value=httpx.Response(
|
|
200,
|
|
json={
|
|
"items": [
|
|
{"version": 1, "versionAttributes": {"aliases": ["old"]}},
|
|
{
|
|
"version": 3,
|
|
"versionAttributes": {"aliases": ["stable", "main"]},
|
|
},
|
|
{"version": 2},
|
|
]
|
|
},
|
|
)
|
|
)
|
|
async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client:
|
|
versions = await client.list_versions("sid")
|
|
|
|
assert [v.version for v in versions] == [3, 2, 1]
|
|
assert versions[0].aliases == ["stable", "main"]
|
|
assert versions[1].aliases == []
|
|
assert versions[2].aliases == ["old"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@respx.mock
|
|
async def test_list_versions_invalid_payload_raises() -> None:
|
|
respx.get(f"{_URL}/sid/versions").mock(
|
|
return_value=httpx.Response(200, json={"items": [{"noVersion": 1}]})
|
|
)
|
|
async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client:
|
|
with pytest.raises(RegistrySkillsError, match="invalid versions response"):
|
|
await client.list_versions("sid")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@respx.mock
|
|
async def test_get_skill_by_id() -> None:
|
|
respx.get(f"{_URL}/sid").mock(
|
|
return_value=httpx.Response(
|
|
200,
|
|
json={
|
|
"skillId": "sid",
|
|
"skill": {"skillName": "n", "skillBody": "b"},
|
|
"version": 3,
|
|
},
|
|
)
|
|
)
|
|
async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client:
|
|
item = await client.get_skill("sid")
|
|
assert item.skill_id == "sid"
|
|
assert item.version == 3
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@respx.mock
|
|
async def test_get_skill_not_found_raises() -> None:
|
|
respx.get(f"{_URL}/missing").mock(return_value=httpx.Response(404))
|
|
async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client:
|
|
with pytest.raises(RegistrySkillsError, match="not found"):
|
|
await client.get_skill("missing")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@respx.mock
|
|
async def test_get_skill_invalid_payload_raises() -> None:
|
|
# A 200 whose body isn't a valid skill object must surface as a registry
|
|
# error, not a bare pydantic ValidationError.
|
|
respx.get(f"{_URL}/sid").mock(
|
|
return_value=httpx.Response(200, json="not-an-object")
|
|
)
|
|
async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client:
|
|
with pytest.raises(RegistrySkillsError, match="invalid skill response"):
|
|
await client.get_skill("sid")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@respx.mock
|
|
async def test_list_catalog_invalid_payload_raises() -> None:
|
|
respx.get(_URL).mock(return_value=httpx.Response(200, json="not-an-object"))
|
|
async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client:
|
|
with pytest.raises(RegistrySkillsError, match="invalid catalog response"):
|
|
await client.list_catalog(page_size=10)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@respx.mock
|
|
async def test_list_catalog_raises_when_page_cap_exceeded() -> None:
|
|
# Every page reports more pages -> the cap is hit with data remaining, which
|
|
# must raise rather than silently return a truncated catalog.
|
|
respx.get(_URL).mock(
|
|
return_value=httpx.Response(200, json=_page("a", next_token="more"))
|
|
)
|
|
async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client:
|
|
with pytest.raises(RegistrySkillsError, match="maximum number of pages"):
|
|
await client.list_catalog(page_size=10)
|