950 lines
36 KiB
Python
950 lines
36 KiB
Python
"""Tests for the admin CLI command group (U8).
|
|
|
|
These tests exercise the Typer command callbacks in
|
|
:mod:`agentkit.cli.admin` by mocking :class:`AdminHttpClient` so no
|
|
real HTTP traffic is generated. They verify that each command:
|
|
- Calls the correct endpoint with the correct method/body/params.
|
|
- Exits 0 on success and prints the expected output.
|
|
- Handles connection / HTTP errors gracefully with a non-zero exit.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import tempfile
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import httpx
|
|
import pytest
|
|
from typer.testing import CliRunner
|
|
|
|
from agentkit.cli.admin import admin_app
|
|
|
|
runner = CliRunner()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_client():
|
|
"""Patch :class:`AdminHttpClient` and return the mock instance.
|
|
|
|
The mock's ``base_url`` is set so error handlers can format messages.
|
|
"""
|
|
with patch("agentkit.cli.admin.AdminHttpClient") as mock_client_class:
|
|
mock = MagicMock()
|
|
mock.base_url = "http://localhost:8001"
|
|
mock_client_class.from_config.return_value = mock
|
|
yield mock
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Department commands
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestDepartmentCommands:
|
|
def test_dept_list_json(self, mock_client):
|
|
"""department list --json outputs raw JSON."""
|
|
mock_client.get.return_value = [
|
|
{
|
|
"id": "dept-1",
|
|
"name": "HR",
|
|
"description": "Human Resources",
|
|
"is_active": True,
|
|
"created_at": "2026-01-01T00:00:00Z",
|
|
}
|
|
]
|
|
result = runner.invoke(admin_app, ["department", "list", "--json"])
|
|
assert result.exit_code == 0
|
|
assert "HR" in result.stdout
|
|
assert "dept-1" in result.stdout
|
|
mock_client.get.assert_called_once_with("/api/v1/admin/departments", params=None)
|
|
|
|
def test_dept_list_table(self, mock_client):
|
|
"""department list renders a Rich table by default."""
|
|
mock_client.get.return_value = [
|
|
{
|
|
"id": "dept-1",
|
|
"name": "Engineering",
|
|
"description": "Eng team",
|
|
"is_active": True,
|
|
"created_at": "2026-01-01",
|
|
}
|
|
]
|
|
result = runner.invoke(admin_app, ["department", "list"])
|
|
assert result.exit_code == 0
|
|
assert "Engineering" in result.stdout
|
|
assert "Departments" in result.stdout
|
|
|
|
def test_dept_list_empty(self, mock_client):
|
|
"""department list with no results shows empty message."""
|
|
mock_client.get.return_value = []
|
|
result = runner.invoke(admin_app, ["department", "list"])
|
|
assert result.exit_code == 0
|
|
assert "No departments" in result.stdout
|
|
|
|
def test_dept_create(self, mock_client):
|
|
"""department create posts the correct body."""
|
|
mock_client.post.return_value = {
|
|
"id": "dept-2",
|
|
"name": "Finance",
|
|
"description": "Finance team",
|
|
}
|
|
result = runner.invoke(
|
|
admin_app,
|
|
[
|
|
"department",
|
|
"create",
|
|
"--name",
|
|
"Finance",
|
|
"--description",
|
|
"Finance team",
|
|
],
|
|
)
|
|
assert result.exit_code == 0
|
|
assert "Finance" in result.stdout
|
|
mock_client.post.assert_called_once_with(
|
|
"/api/v1/admin/departments",
|
|
json={"name": "Finance", "description": "Finance team"},
|
|
)
|
|
|
|
def test_dept_update(self, mock_client):
|
|
"""department update patches only provided fields."""
|
|
mock_client.patch.return_value = {"id": "dept-1", "name": "HR Updated"}
|
|
result = runner.invoke(
|
|
admin_app,
|
|
["department", "update", "dept-1", "--name", "HR Updated"],
|
|
)
|
|
assert result.exit_code == 0
|
|
mock_client.patch.assert_called_once_with(
|
|
"/api/v1/admin/departments/dept-1",
|
|
json={"name": "HR Updated"},
|
|
)
|
|
|
|
def test_dept_update_no_fields_errors(self, mock_client):
|
|
"""department update without --name or --description exits non-zero."""
|
|
result = runner.invoke(admin_app, ["department", "update", "dept-1"])
|
|
assert result.exit_code != 0
|
|
assert "Provide" in result.stdout or "Error" in result.stdout
|
|
|
|
def test_dept_delete(self, mock_client):
|
|
"""department delete calls DELETE and prints success."""
|
|
mock_client.delete.return_value = {"deleted": True}
|
|
result = runner.invoke(admin_app, ["department", "delete", "dept-1"])
|
|
assert result.exit_code == 0
|
|
assert "deleted" in result.stdout.lower()
|
|
mock_client.delete.assert_called_once_with("/api/v1/admin/departments/dept-1", params=None)
|
|
|
|
def test_dept_enable(self, mock_client):
|
|
"""department enable calls the enable endpoint."""
|
|
mock_client.post.return_value = {"id": "dept-1", "is_active": True}
|
|
result = runner.invoke(admin_app, ["department", "enable", "dept-1"])
|
|
assert result.exit_code == 0
|
|
mock_client.post.assert_called_once_with(
|
|
"/api/v1/admin/departments/dept-1/enable", json=None
|
|
)
|
|
|
|
def test_dept_disable(self, mock_client):
|
|
"""department disable calls the disable endpoint."""
|
|
mock_client.post.return_value = {"id": "dept-1", "is_active": False}
|
|
result = runner.invoke(admin_app, ["department", "disable", "dept-1"])
|
|
assert result.exit_code == 0
|
|
mock_client.post.assert_called_once_with(
|
|
"/api/v1/admin/departments/dept-1/disable", json=None
|
|
)
|
|
|
|
def test_dept_bind_skill(self, mock_client):
|
|
"""department bind-skill posts to the binding endpoint."""
|
|
mock_client.post.return_value = {"bound": True}
|
|
result = runner.invoke(
|
|
admin_app,
|
|
["department", "bind-skill", "dept-1", "content_generator"],
|
|
)
|
|
assert result.exit_code == 0
|
|
mock_client.post.assert_called_once_with(
|
|
"/api/v1/admin/departments/dept-1/skills/content_generator",
|
|
json=None,
|
|
)
|
|
|
|
def test_dept_unbind_skill(self, mock_client):
|
|
"""department unbind-skill deletes the binding."""
|
|
mock_client.delete.return_value = {"unbound": True}
|
|
result = runner.invoke(
|
|
admin_app,
|
|
["department", "unbind-skill", "dept-1", "content_generator"],
|
|
)
|
|
assert result.exit_code == 0
|
|
mock_client.delete.assert_called_once_with(
|
|
"/api/v1/admin/departments/dept-1/skills/content_generator",
|
|
params=None,
|
|
)
|
|
|
|
def test_dept_bind_kb(self, mock_client):
|
|
"""department bind-kb posts to the KB binding endpoint."""
|
|
mock_client.post.return_value = {"bound": True}
|
|
result = runner.invoke(
|
|
admin_app,
|
|
["department", "bind-kb", "dept-1", "kb-src-1"],
|
|
)
|
|
assert result.exit_code == 0
|
|
mock_client.post.assert_called_once_with(
|
|
"/api/v1/admin/departments/dept-1/kb/kb-src-1",
|
|
json=None,
|
|
)
|
|
|
|
def test_dept_unbind_kb(self, mock_client):
|
|
"""department unbind-kb deletes the KB binding."""
|
|
mock_client.delete.return_value = {"unbound": True}
|
|
result = runner.invoke(
|
|
admin_app,
|
|
["department", "unbind-kb", "dept-1", "kb-src-1"],
|
|
)
|
|
assert result.exit_code == 0
|
|
mock_client.delete.assert_called_once_with(
|
|
"/api/v1/admin/departments/dept-1/kb/kb-src-1",
|
|
params=None,
|
|
)
|
|
|
|
def test_dept_list_skills(self, mock_client):
|
|
"""department list-skills returns bound skill names."""
|
|
mock_client.get.return_value = ["content_generator", "code_reviewer"]
|
|
result = runner.invoke(admin_app, ["department", "list-skills", "dept-1"])
|
|
assert result.exit_code == 0
|
|
assert "content_generator" in result.stdout
|
|
|
|
def test_dept_list_quotas(self, mock_client):
|
|
"""department list-quotas returns quota rows."""
|
|
mock_client.get.return_value = [
|
|
{"quota_type": "token", "limit_value": 100000, "period": "daily"}
|
|
]
|
|
result = runner.invoke(admin_app, ["department", "list-quotas", "dept-1"])
|
|
assert result.exit_code == 0
|
|
assert "token" in result.stdout
|
|
|
|
def test_dept_set_quota_token(self, mock_client):
|
|
"""department set-quota with token type sends int limit."""
|
|
mock_client.put.return_value = {"quota_type": "token", "limit_value": 100000}
|
|
result = runner.invoke(
|
|
admin_app,
|
|
[
|
|
"department",
|
|
"set-quota",
|
|
"dept-1",
|
|
"--type",
|
|
"token",
|
|
"--limit",
|
|
"100000",
|
|
],
|
|
)
|
|
assert result.exit_code == 0
|
|
mock_client.put.assert_called_once_with(
|
|
"/api/v1/admin/departments/dept-1/quotas",
|
|
json={
|
|
"quota_type": "token",
|
|
"limit_value": 100000,
|
|
"period": "daily",
|
|
},
|
|
)
|
|
|
|
def test_dept_set_quota_whitelist(self, mock_client):
|
|
"""department set-quota with model_whitelist sends list."""
|
|
mock_client.put.return_value = {"quota_type": "model_whitelist"}
|
|
result = runner.invoke(
|
|
admin_app,
|
|
[
|
|
"department",
|
|
"set-quota",
|
|
"dept-1",
|
|
"--type",
|
|
"model_whitelist",
|
|
"--limit",
|
|
"gpt-4,claude-3",
|
|
],
|
|
)
|
|
assert result.exit_code == 0
|
|
mock_client.put.assert_called_once_with(
|
|
"/api/v1/admin/departments/dept-1/quotas",
|
|
json={
|
|
"quota_type": "model_whitelist",
|
|
"limit_value": ["gpt-4", "claude-3"],
|
|
"period": "daily",
|
|
},
|
|
)
|
|
|
|
def test_dept_delete_quota(self, mock_client):
|
|
"""department delete-quota passes query params."""
|
|
mock_client.delete.return_value = {"deleted": True}
|
|
result = runner.invoke(
|
|
admin_app,
|
|
[
|
|
"department",
|
|
"delete-quota",
|
|
"dept-1",
|
|
"--type",
|
|
"token",
|
|
"--period",
|
|
"monthly",
|
|
],
|
|
)
|
|
assert result.exit_code == 0
|
|
mock_client.delete.assert_called_once_with(
|
|
"/api/v1/admin/departments/dept-1/quotas",
|
|
params={"quota_type": "token", "period": "monthly"},
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# User commands
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestUserCommands:
|
|
def test_user_list(self, mock_client):
|
|
"""user list returns users table."""
|
|
mock_client.get.return_value = [
|
|
{
|
|
"id": "user-1",
|
|
"username": "alice",
|
|
"email": "alice@example.com",
|
|
"role": "member",
|
|
"is_active": True,
|
|
}
|
|
]
|
|
result = runner.invoke(admin_app, ["user", "list"])
|
|
assert result.exit_code == 0
|
|
assert "alice" in result.stdout
|
|
mock_client.get.assert_called_once_with("/api/v1/admin/users", params=None)
|
|
|
|
def test_user_list_with_department_filter(self, mock_client):
|
|
"""user list --department-id passes the filter."""
|
|
mock_client.get.return_value = []
|
|
result = runner.invoke(admin_app, ["user", "list", "--department-id", "dept-1"])
|
|
assert result.exit_code == 0
|
|
mock_client.get.assert_called_once_with(
|
|
"/api/v1/admin/users", params={"department_id": "dept-1"}
|
|
)
|
|
|
|
def test_user_create(self, mock_client):
|
|
"""user create posts the user body."""
|
|
mock_client.post.return_value = {
|
|
"id": "user-2",
|
|
"username": "bob",
|
|
"email": "bob@example.com",
|
|
}
|
|
result = runner.invoke(
|
|
admin_app,
|
|
[
|
|
"user",
|
|
"create",
|
|
"--username",
|
|
"bob",
|
|
"--email",
|
|
"bob@example.com",
|
|
"--password",
|
|
"secret123",
|
|
],
|
|
)
|
|
assert result.exit_code == 0
|
|
assert "bob" in result.stdout
|
|
mock_client.post.assert_called_once_with(
|
|
"/api/v1/admin/users",
|
|
json={
|
|
"username": "bob",
|
|
"email": "bob@example.com",
|
|
"password": "secret123",
|
|
"role": "member",
|
|
},
|
|
)
|
|
|
|
def test_user_create_with_departments(self, mock_client):
|
|
"""user create --department-ids splits the comma list."""
|
|
mock_client.post.return_value = {"id": "user-3", "username": "carol"}
|
|
result = runner.invoke(
|
|
admin_app,
|
|
[
|
|
"user",
|
|
"create",
|
|
"--username",
|
|
"carol",
|
|
"--email",
|
|
"carol@example.com",
|
|
"--password",
|
|
"secret123",
|
|
"--department-ids",
|
|
"dept-1,dept-2",
|
|
],
|
|
)
|
|
assert result.exit_code == 0
|
|
body = mock_client.post.call_args.kwargs["json"]
|
|
assert body["department_ids"] == ["dept-1", "dept-2"]
|
|
|
|
def test_user_update_role(self, mock_client):
|
|
"""user update --role patches only the role field."""
|
|
mock_client.patch.return_value = {"id": "user-1", "role": "admin"}
|
|
result = runner.invoke(admin_app, ["user", "update", "user-1", "--role", "admin"])
|
|
assert result.exit_code == 0
|
|
mock_client.patch.assert_called_once_with(
|
|
"/api/v1/admin/users/user-1",
|
|
json={"role": "admin"},
|
|
)
|
|
|
|
def test_user_delete(self, mock_client):
|
|
"""user delete soft-deletes the user."""
|
|
mock_client.delete.return_value = {"deleted": True}
|
|
result = runner.invoke(admin_app, ["user", "delete", "user-1"])
|
|
assert result.exit_code == 0
|
|
mock_client.delete.assert_called_once_with("/api/v1/admin/users/user-1", params=None)
|
|
|
|
def test_user_reset_password(self, mock_client):
|
|
"""user reset-password posts the new password."""
|
|
mock_client.post.return_value = {"reset": True}
|
|
result = runner.invoke(
|
|
admin_app,
|
|
["user", "reset-password", "user-1", "--password", "newpass"],
|
|
)
|
|
assert result.exit_code == 0
|
|
mock_client.post.assert_called_once_with(
|
|
"/api/v1/admin/users/user-1/reset-password",
|
|
json={"new_password": "newpass"},
|
|
)
|
|
|
|
def test_user_assign_department(self, mock_client):
|
|
"""user assign-department posts to the assignment endpoint."""
|
|
mock_client.post.return_value = {"assigned": True}
|
|
result = runner.invoke(admin_app, ["user", "assign-department", "user-1", "dept-1"])
|
|
assert result.exit_code == 0
|
|
mock_client.post.assert_called_once_with(
|
|
"/api/v1/admin/users/user-1/departments/dept-1",
|
|
json=None,
|
|
)
|
|
|
|
def test_user_remove_department(self, mock_client):
|
|
"""user remove-department deletes the assignment."""
|
|
mock_client.delete.return_value = {"removed": True}
|
|
result = runner.invoke(admin_app, ["user", "remove-department", "user-1", "dept-1"])
|
|
assert result.exit_code == 0
|
|
mock_client.delete.assert_called_once_with(
|
|
"/api/v1/admin/users/user-1/departments/dept-1",
|
|
params=None,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# LLM commands
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestLlmCommands:
|
|
def test_llm_list_providers(self, mock_client):
|
|
"""llm list-providers returns the providers table."""
|
|
mock_client.get.return_value = [
|
|
{
|
|
"name": "openai",
|
|
"type": "openai",
|
|
"base_url": "",
|
|
"api_key": "sk-***",
|
|
"max_tokens": 4096,
|
|
"timeout": 60.0,
|
|
}
|
|
]
|
|
result = runner.invoke(admin_app, ["llm", "list-providers"])
|
|
assert result.exit_code == 0
|
|
assert "openai" in result.stdout
|
|
|
|
def test_llm_add_provider(self, mock_client):
|
|
"""llm add-provider posts the provider config."""
|
|
mock_client.post.return_value = {"name": "anthropic"}
|
|
result = runner.invoke(
|
|
admin_app,
|
|
[
|
|
"llm",
|
|
"add-provider",
|
|
"--name",
|
|
"anthropic",
|
|
"--api-key",
|
|
"sk-ant-xxx",
|
|
],
|
|
)
|
|
assert result.exit_code == 0
|
|
body = mock_client.post.call_args.kwargs["json"]
|
|
assert body["name"] == "anthropic"
|
|
assert body["api_key"] == "sk-ant-xxx"
|
|
|
|
def test_llm_update_provider(self, mock_client):
|
|
"""llm update-provider patches the provider."""
|
|
mock_client.patch.return_value = {"name": "openai"}
|
|
result = runner.invoke(
|
|
admin_app,
|
|
["llm", "update-provider", "openai", "--max-tokens", "8192"],
|
|
)
|
|
assert result.exit_code == 0
|
|
mock_client.patch.assert_called_once_with(
|
|
"/api/v1/admin/llm/providers/openai",
|
|
json={"max_tokens": 8192},
|
|
)
|
|
|
|
def test_llm_delete_provider(self, mock_client):
|
|
"""llm delete-provider deletes the provider."""
|
|
mock_client.delete.return_value = {"deleted": True}
|
|
result = runner.invoke(admin_app, ["llm", "delete-provider", "openai"])
|
|
assert result.exit_code == 0
|
|
mock_client.delete.assert_called_once_with(
|
|
"/api/v1/admin/llm/providers/openai", params=None
|
|
)
|
|
|
|
def test_llm_set_api_key(self, mock_client):
|
|
"""llm set-api-key posts the new key."""
|
|
mock_client.post.return_value = {"name": "openai", "api_key": "sk-***"}
|
|
result = runner.invoke(
|
|
admin_app,
|
|
["llm", "set-api-key", "openai", "--api-key", "sk-new"],
|
|
)
|
|
assert result.exit_code == 0
|
|
mock_client.post.assert_called_once_with(
|
|
"/api/v1/admin/llm/providers/openai/api-key",
|
|
json={"api_key": "sk-new"},
|
|
)
|
|
|
|
def test_llm_list_fallbacks(self, mock_client):
|
|
"""llm list-fallbacks returns the fallback map."""
|
|
mock_client.get.return_value = {"gpt-4": ["openai/gpt-4", "anthropic/claude-3"]}
|
|
result = runner.invoke(admin_app, ["llm", "list-fallbacks"])
|
|
assert result.exit_code == 0
|
|
assert "gpt-4" in result.stdout
|
|
|
|
def test_llm_set_fallback(self, mock_client):
|
|
"""llm set-fallback puts the chain list."""
|
|
mock_client.put.return_value = {"model": "gpt-4", "chain": ["a", "b"]}
|
|
result = runner.invoke(
|
|
admin_app,
|
|
["llm", "set-fallback", "gpt-4", "--chain", "openai/gpt-4,anthropic/claude-3"],
|
|
)
|
|
assert result.exit_code == 0
|
|
mock_client.put.assert_called_once_with(
|
|
"/api/v1/admin/llm/fallbacks/gpt-4",
|
|
json={"chain": ["openai/gpt-4", "anthropic/claude-3"]},
|
|
)
|
|
|
|
def test_llm_delete_fallback(self, mock_client):
|
|
"""llm delete-fallback deletes the chain."""
|
|
mock_client.delete.return_value = {"deleted": True}
|
|
result = runner.invoke(admin_app, ["llm", "delete-fallback", "gpt-4"])
|
|
assert result.exit_code == 0
|
|
mock_client.delete.assert_called_once_with("/api/v1/admin/llm/fallbacks/gpt-4", params=None)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Skill commands
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSkillCommands:
|
|
def test_skill_list(self, mock_client):
|
|
"""skill list calls GET /skills (not /admin/skills)."""
|
|
mock_client.get.return_value = [
|
|
{"name": "content_generator", "agent_type": "llm", "description": "Gen"}
|
|
]
|
|
result = runner.invoke(admin_app, ["skill", "list"])
|
|
assert result.exit_code == 0
|
|
mock_client.get.assert_called_once_with("/api/v1/skills", params=None)
|
|
|
|
def test_skill_enable(self, mock_client):
|
|
"""skill enable posts to the enable endpoint."""
|
|
mock_client.post.return_value = {"enabled": True, "skill_name": "x"}
|
|
result = runner.invoke(admin_app, ["skill", "enable", "x"])
|
|
assert result.exit_code == 0
|
|
mock_client.post.assert_called_once_with("/api/v1/admin/skills/x/enable", json=None)
|
|
|
|
def test_skill_disable(self, mock_client):
|
|
"""skill disable posts to the disable endpoint."""
|
|
mock_client.post.return_value = {"disabled": True}
|
|
result = runner.invoke(admin_app, ["skill", "disable", "x"])
|
|
assert result.exit_code == 0
|
|
mock_client.post.assert_called_once_with("/api/v1/admin/skills/x/disable", json=None)
|
|
|
|
def test_skill_reload(self, mock_client):
|
|
"""skill reload posts to the reload endpoint."""
|
|
mock_client.post.return_value = {"name": "x", "reloaded": True}
|
|
result = runner.invoke(admin_app, ["skill", "reload", "x"])
|
|
assert result.exit_code == 0
|
|
mock_client.post.assert_called_once_with("/api/v1/admin/skills/x/reload", json=None)
|
|
|
|
def test_skill_import(self, mock_client):
|
|
"""skill import reads the YAML file and posts its content."""
|
|
mock_client.post.return_value = {"name": "imported_skill"}
|
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
|
|
f.write("name: imported_skill\ndescription: test\n")
|
|
f.flush()
|
|
path = f.name
|
|
try:
|
|
result = runner.invoke(admin_app, ["skill", "import", path])
|
|
assert result.exit_code == 0
|
|
body = mock_client.post.call_args.kwargs["json"]
|
|
assert "imported_skill" in body["yaml_content"]
|
|
finally:
|
|
os.unlink(path)
|
|
|
|
def test_skill_import_missing_file(self, mock_client):
|
|
"""skill import with a missing file exits non-zero."""
|
|
result = runner.invoke(admin_app, ["skill", "import", "/nonexistent.yaml"])
|
|
assert result.exit_code != 0
|
|
assert "not found" in result.stdout.lower()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# KB commands
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestKbCommands:
|
|
def test_kb_list_documents(self, mock_client):
|
|
"""kb list-documents returns the documents table."""
|
|
mock_client.get.return_value = {
|
|
"documents": [
|
|
{
|
|
"id": "doc-1",
|
|
"filename": "spec.md",
|
|
"source_id": "src-1",
|
|
"department_id": "dept-1",
|
|
"size": 1024,
|
|
"created_at": "2026-01-01",
|
|
}
|
|
]
|
|
}
|
|
result = runner.invoke(admin_app, ["kb", "list-documents"])
|
|
assert result.exit_code == 0
|
|
assert "spec.md" in result.stdout
|
|
|
|
def test_kb_upload(self, mock_client):
|
|
"""kb upload reads the file and posts the content."""
|
|
mock_client.post.return_value = {"id": "doc-2", "filename": "x.md"}
|
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as f:
|
|
f.write("# Title\nbody")
|
|
f.flush()
|
|
path = f.name
|
|
try:
|
|
result = runner.invoke(
|
|
admin_app,
|
|
[
|
|
"kb",
|
|
"upload",
|
|
"--filename",
|
|
"x.md",
|
|
"--content-file",
|
|
path,
|
|
"--source-id",
|
|
"src-1",
|
|
],
|
|
)
|
|
assert result.exit_code == 0
|
|
body = mock_client.post.call_args.kwargs["json"]
|
|
assert body["filename"] == "x.md"
|
|
assert "# Title" in body["content"]
|
|
finally:
|
|
os.unlink(path)
|
|
|
|
def test_kb_delete(self, mock_client):
|
|
"""kb delete deletes the document."""
|
|
mock_client.delete.return_value = {"deleted": True}
|
|
result = runner.invoke(admin_app, ["kb", "delete", "doc-1"])
|
|
assert result.exit_code == 0
|
|
mock_client.delete.assert_called_once_with("/api/v1/admin/kb/documents/doc-1", params=None)
|
|
|
|
def test_kb_sync(self, mock_client):
|
|
"""kb sync triggers a source sync."""
|
|
mock_client.post.return_value = {"synced": True}
|
|
result = runner.invoke(admin_app, ["kb", "sync", "src-1"])
|
|
assert result.exit_code == 0
|
|
mock_client.post.assert_called_once_with("/api/v1/admin/kb/sources/src-1/sync", json=None)
|
|
|
|
def test_kb_rebuild(self, mock_client):
|
|
"""kb rebuild triggers a source rebuild."""
|
|
mock_client.post.return_value = {"rebuilt": True}
|
|
result = runner.invoke(admin_app, ["kb", "rebuild", "src-1"])
|
|
assert result.exit_code == 0
|
|
mock_client.post.assert_called_once_with(
|
|
"/api/v1/admin/kb/sources/src-1/rebuild", json=None
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Usage commands
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestUsageCommands:
|
|
def test_usage_summary(self, mock_client):
|
|
"""usage summary returns aggregated metrics."""
|
|
mock_client.get.return_value = {
|
|
"total_requests": 100,
|
|
"total_tokens": 50000,
|
|
"total_cost": 1.23,
|
|
}
|
|
result = runner.invoke(admin_app, ["usage", "summary"])
|
|
assert result.exit_code == 0
|
|
assert "total_requests" in result.stdout
|
|
assert "100" in result.stdout
|
|
|
|
def test_usage_summary_json(self, mock_client):
|
|
"""usage summary --json outputs raw JSON."""
|
|
mock_client.get.return_value = {"total_requests": 5}
|
|
result = runner.invoke(admin_app, ["usage", "summary", "--json"])
|
|
assert result.exit_code == 0
|
|
assert "total_requests" in result.stdout
|
|
assert "5" in result.stdout
|
|
|
|
def test_usage_timeseries(self, mock_client):
|
|
"""usage timeseries returns bucketed rows."""
|
|
mock_client.get.return_value = [
|
|
{"bucket": "2026-01-01", "requests": 10, "tokens": 1000, "cost": 0.1}
|
|
]
|
|
result = runner.invoke(admin_app, ["usage", "timeseries", "--interval", "day"])
|
|
assert result.exit_code == 0
|
|
assert "2026-01-01" in result.stdout
|
|
params = mock_client.get.call_args.kwargs["params"]
|
|
assert params["interval"] == "day"
|
|
|
|
def test_usage_by_model(self, mock_client):
|
|
"""usage by-model returns per-model rows."""
|
|
mock_client.get.return_value = [
|
|
{"model": "gpt-4", "requests": 50, "tokens": 20000, "cost": 0.8}
|
|
]
|
|
result = runner.invoke(admin_app, ["usage", "by-model"])
|
|
assert result.exit_code == 0
|
|
assert "gpt-4" in result.stdout
|
|
|
|
def test_usage_top_users(self, mock_client):
|
|
"""usage top-users returns ranked rows."""
|
|
mock_client.get.return_value = [
|
|
{
|
|
"user_id": "user-1",
|
|
"requests": 100,
|
|
"tokens": 50000,
|
|
"cost": 1.5,
|
|
}
|
|
]
|
|
result = runner.invoke(admin_app, ["usage", "top-users", "--limit", "5"])
|
|
assert result.exit_code == 0
|
|
assert "user-1" in result.stdout
|
|
params = mock_client.get.call_args.kwargs["params"]
|
|
assert params["limit"] == 5
|
|
|
|
def test_usage_export_stdout(self, mock_client):
|
|
"""usage export prints CSV to stdout."""
|
|
mock_client.get_text.return_value = "user_id,tokens\nalice,1000\n"
|
|
result = runner.invoke(admin_app, ["usage", "export", "--format", "csv"])
|
|
assert result.exit_code == 0
|
|
assert "user_id" in result.stdout
|
|
|
|
def test_usage_export_to_file(self, mock_client):
|
|
"""usage export --output writes to a file."""
|
|
mock_client.get_text.return_value = "user_id,tokens\nalice,1000\n"
|
|
with tempfile.NamedTemporaryFile(mode="r", suffix=".csv", delete=False) as f:
|
|
path = f.name
|
|
try:
|
|
result = runner.invoke(admin_app, ["usage", "export", "--output", path])
|
|
assert result.exit_code == 0
|
|
with open(path) as f:
|
|
content = f.read()
|
|
assert "user_id" in content
|
|
finally:
|
|
os.unlink(path)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Login command
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestLoginCommand:
|
|
def test_login_success(self):
|
|
"""admin login saves the token to the config file."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
config_path = os.path.join(tmpdir, "admin_config.yaml")
|
|
with patch("agentkit.cli.admin.AdminHttpClient") as mock_cls:
|
|
mock_instance = MagicMock()
|
|
mock_instance.login.return_value = "jwt-token-123"
|
|
mock_cls.return_value = mock_instance
|
|
result = runner.invoke(
|
|
admin_app,
|
|
[
|
|
"login",
|
|
"--username",
|
|
"admin",
|
|
"--password",
|
|
"secret",
|
|
"--config-path",
|
|
config_path,
|
|
],
|
|
)
|
|
assert result.exit_code == 0
|
|
assert "Login successful" in result.stdout
|
|
import yaml
|
|
|
|
with open(config_path) as f:
|
|
cfg = yaml.safe_load(f)
|
|
assert cfg["token"] == "jwt-token-123"
|
|
assert cfg["server_url"] == "http://localhost:18001"
|
|
|
|
def test_login_with_server_url(self):
|
|
"""admin login --server-url saves the custom URL."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
config_path = os.path.join(tmpdir, "admin_config.yaml")
|
|
with patch("agentkit.cli.admin.AdminHttpClient") as mock_cls:
|
|
mock_instance = MagicMock()
|
|
mock_instance.login.return_value = "tok"
|
|
mock_cls.return_value = mock_instance
|
|
result = runner.invoke(
|
|
admin_app,
|
|
[
|
|
"login",
|
|
"--username",
|
|
"admin",
|
|
"--password",
|
|
"secret",
|
|
"--server-url",
|
|
"http://my-server:9000",
|
|
"--config-path",
|
|
config_path,
|
|
],
|
|
)
|
|
assert result.exit_code == 0
|
|
import yaml
|
|
|
|
with open(config_path) as f:
|
|
cfg = yaml.safe_load(f)
|
|
assert cfg["server_url"] == "http://my-server:9000"
|
|
|
|
def test_login_failure_auth_error(self):
|
|
"""admin login with bad credentials exits non-zero."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
config_path = os.path.join(tmpdir, "admin_config.yaml")
|
|
with patch("agentkit.cli.admin.AdminHttpClient") as mock_cls:
|
|
mock_instance = MagicMock()
|
|
# Simulate a 401 from the server.
|
|
response = MagicMock(spec=httpx.Response)
|
|
response.status_code = 401
|
|
response.text = "Unauthorized"
|
|
response.json.return_value = {"detail": "Invalid credentials"}
|
|
mock_instance.login.side_effect = httpx.HTTPStatusError(
|
|
"Unauthorized", request=MagicMock(), response=response
|
|
)
|
|
mock_cls.return_value = mock_instance
|
|
result = runner.invoke(
|
|
admin_app,
|
|
[
|
|
"login",
|
|
"--username",
|
|
"admin",
|
|
"--password",
|
|
"wrong",
|
|
"--config-path",
|
|
config_path,
|
|
],
|
|
)
|
|
assert result.exit_code != 0
|
|
assert "Authentication failed" in result.stdout
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Error handling
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestErrorHandling:
|
|
def test_connection_error(self, mock_client):
|
|
"""Connection errors produce a friendly message and exit 1."""
|
|
mock_client.get.side_effect = httpx.ConnectError("connection refused")
|
|
result = runner.invoke(admin_app, ["department", "list"])
|
|
assert result.exit_code != 0
|
|
assert "Cannot connect" in result.stdout
|
|
|
|
def test_auth_error_401(self, mock_client):
|
|
"""401 errors prompt the user to run 'admin login'."""
|
|
response = MagicMock(spec=httpx.Response)
|
|
response.status_code = 401
|
|
response.text = "Unauthorized"
|
|
response.json.return_value = {"detail": "Unauthorized"}
|
|
mock_client.get.side_effect = httpx.HTTPStatusError(
|
|
"Unauthorized", request=MagicMock(), response=response
|
|
)
|
|
result = runner.invoke(admin_app, ["department", "list"])
|
|
assert result.exit_code != 0
|
|
assert "Authentication failed" in result.stdout
|
|
|
|
def test_forbidden_error_403(self, mock_client):
|
|
"""403 errors explain admin permission is required."""
|
|
response = MagicMock(spec=httpx.Response)
|
|
response.status_code = 403
|
|
response.text = "Forbidden"
|
|
response.json.return_value = {"detail": "Forbidden"}
|
|
mock_client.get.side_effect = httpx.HTTPStatusError(
|
|
"Forbidden", request=MagicMock(), response=response
|
|
)
|
|
result = runner.invoke(admin_app, ["department", "list"])
|
|
assert result.exit_code != 0
|
|
assert "Admin permission required" in result.stdout
|
|
|
|
def test_not_found_error_404(self, mock_client):
|
|
"""404 errors print a not-found message."""
|
|
response = MagicMock(spec=httpx.Response)
|
|
response.status_code = 404
|
|
response.text = "Not Found"
|
|
response.json.return_value = {"detail": "Not found"}
|
|
mock_client.get.side_effect = httpx.HTTPStatusError(
|
|
"Not Found", request=MagicMock(), response=response
|
|
)
|
|
result = runner.invoke(admin_app, ["department", "list"])
|
|
assert result.exit_code != 0
|
|
assert "not found" in result.stdout.lower()
|
|
|
|
def test_conflict_error_409(self, mock_client):
|
|
"""409 errors print the conflict detail."""
|
|
response = MagicMock(spec=httpx.Response)
|
|
response.status_code = 409
|
|
response.text = "Conflict"
|
|
response.json.return_value = {"detail": "Name already exists"}
|
|
mock_client.post.side_effect = httpx.HTTPStatusError(
|
|
"Conflict", request=MagicMock(), response=response
|
|
)
|
|
result = runner.invoke(
|
|
admin_app,
|
|
["department", "create", "--name", "dup"],
|
|
)
|
|
assert result.exit_code != 0
|
|
assert "Conflict" in result.stdout
|
|
assert "Name already exists" in result.stdout
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Help / registration smoke tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestAdminAppRegistration:
|
|
def test_admin_help_shows_groups(self):
|
|
"""agentkit admin --help lists all sub-groups."""
|
|
result = runner.invoke(admin_app, ["--help"])
|
|
assert result.exit_code == 0
|
|
for group in ("department", "user", "llm", "skill", "kb", "usage", "login"):
|
|
assert group in result.stdout
|
|
|
|
def test_admin_department_help(self):
|
|
"""agentkit admin department --help lists department commands."""
|
|
result = runner.invoke(admin_app, ["department", "--help"])
|
|
assert result.exit_code == 0
|
|
for cmd in ("list", "create", "update", "delete", "bind-skill", "unbind-skill"):
|
|
assert cmd in result.stdout
|
|
|
|
def test_admin_registered_on_main_app(self):
|
|
"""The admin sub-app is registered on the main agentkit app."""
|
|
from agentkit.cli.main import app as main_app
|
|
|
|
result = runner.invoke(main_app, ["--help"])
|
|
assert result.exit_code == 0
|
|
assert "admin" in result.stdout
|