agnes-the-ai-analyst/tests/test_cli_store.py
ZdenekSrotyr 16373d6b0b feat(cli): agnes store + agnes my-stack commands
Adds CLI coverage for the new REST surface introduced in this PR:

  agnes store list / show / install / uninstall / upload / delete
  agnes my-stack show / toggle

Covers 11 of the 15 new endpoints — listing, detail, install/uninstall,
upload (multipart), delete, my-stack get + curated toggle. Photo / docs
download endpoints intentionally skipped; analyst-side automation rarely
needs raw bytes back, and the web UI already covers them.

cli/v2_client.py: api_post_multipart + api_put_multipart helpers (httpx
files= passthrough). api_delete + api_put_json fillers were already
needed for non-multipart writes; added together.

Tests: tests/test_cli_store.py — help-text smoke tests + happy-path
mocked tests for list, install, upload, my-stack show, my-stack toggle.
12 new tests, all green.
2026-05-05 08:18:12 +02:00

189 lines
5.7 KiB
Python

"""Tests for `agnes store` and `agnes my-stack` Typer wrappers.
Smoke + happy-path. Network calls are mocked so tests don't depend on a
running server.
"""
from __future__ import annotations
import re
from typer.testing import CliRunner
from cli.commands.my_stack import my_stack_app
from cli.commands.store import store_app
runner = CliRunner()
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
def _clean(s: str) -> str:
return _ANSI_RE.sub("", s)
# ---------------------------------------------------------------------------
# Help-text smoke tests — guard against accidental command renames.
# ---------------------------------------------------------------------------
def test_store_help_lists_subcommands():
r = runner.invoke(store_app, ["--help"])
assert r.exit_code == 0
out = _clean(r.output)
for cmd in ("list", "show", "install", "uninstall", "upload", "delete"):
assert cmd in out, f"missing subcommand {cmd!r} in help"
def test_my_stack_help_lists_subcommands():
r = runner.invoke(my_stack_app, ["--help"])
assert r.exit_code == 0
out = _clean(r.output)
for cmd in ("show", "toggle"):
assert cmd in out
def test_store_list_default_help():
r = runner.invoke(store_app, ["list", "--help"])
assert r.exit_code == 0
out = _clean(r.output)
for opt in ("--type", "--category", "--search", "--owner", "--limit", "--skip", "--json"):
assert opt in out
# ---------------------------------------------------------------------------
# Happy-path mocked tests.
# ---------------------------------------------------------------------------
def test_store_list_renders_table(monkeypatch):
sample = {
"items": [
{
"id": "abc123",
"type": "skill",
"name": "code-review",
"owner_username": "alice",
"install_count": 5,
"version": "deadbeef00000000",
},
],
"total": 1,
"skip": 0,
"limit": 24,
}
import cli.commands.store as store_mod
monkeypatch.setattr(store_mod, "api_get_json", lambda *a, **kw: sample)
r = runner.invoke(store_app, ["list"])
assert r.exit_code == 0, r.output
out = _clean(r.output)
assert "1 entit" in out
assert "code-review" in out
assert "alice" in out
def test_store_install_calls_api(monkeypatch):
captured: dict = {}
def _post(path, payload):
captured["path"] = path
captured["payload"] = payload
return {"entity_id": "xyz", "installed": True}
import cli.commands.store as store_mod
monkeypatch.setattr(store_mod, "api_post_json", _post)
r = runner.invoke(store_app, ["install", "xyz"])
assert r.exit_code == 0, r.output
assert captured["path"] == "/api/store/entities/xyz/install"
assert "Installed" in _clean(r.output)
def test_store_upload_sends_multipart(monkeypatch, tmp_path):
captured: dict = {}
def _multipart(path, *, files, data):
captured["path"] = path
captured["data"] = data
captured["files_keys"] = list(files.keys())
return {
"id": "new-id",
"name": data.get("name", "fallback"),
"invocation_name": "fallback-by-someone",
"version": "abcd1234",
}
import cli.commands.store as store_mod
monkeypatch.setattr(store_mod, "api_post_multipart", _multipart)
zip_path = tmp_path / "skill.zip"
zip_path.write_bytes(b"PK\x03\x04fake-zip-content")
r = runner.invoke(
store_app,
["upload", "skill", str(zip_path), "--name", "my-skill", "--description", "d"],
)
assert r.exit_code == 0, r.output
assert captured["path"] == "/api/store/entities"
assert captured["data"]["type"] == "skill"
assert captured["data"]["name"] == "my-skill"
assert captured["data"]["description"] == "d"
assert captured["files_keys"] == ["file"]
def test_my_stack_show_renders(monkeypatch):
sample = {
"curated": [
{
"marketplace_id": "official",
"marketplace_slug": "official",
"plugin_name": "alpha",
"manifest_name": "alpha",
"version": "1.0",
"enabled": True,
},
],
"store": [
{
"entity_id": "e1",
"type": "skill",
"name": "code-review",
"owner_username": "alice",
"version": "abcd",
"invocation_name": "code-review-by-alice",
"install_count": 1,
},
],
}
import cli.commands.my_stack as ms_mod
monkeypatch.setattr(ms_mod, "api_get_json", lambda *a, **kw: sample)
r = runner.invoke(my_stack_app, ["show"])
assert r.exit_code == 0, r.output
out = _clean(r.output)
assert "Curated" in out and "alpha" in out
assert "From Store" in out and "code-review-by-alice" in out
def test_my_stack_toggle_requires_on_or_off():
r = runner.invoke(my_stack_app, ["toggle", "official", "alpha"])
assert r.exit_code == 2
assert "exactly one" in _clean(r.output) or "exactly one" in _clean(r.stderr or "")
def test_my_stack_toggle_writes_put(monkeypatch):
captured: dict = {}
def _put(path, payload):
captured["path"] = path
captured["payload"] = payload
return {"ok": True}
import cli.commands.my_stack as ms_mod
monkeypatch.setattr(ms_mod, "api_put_json", _put)
r = runner.invoke(my_stack_app, ["toggle", "official", "alpha", "--off"])
assert r.exit_code == 0, r.output
assert captured["path"] == "/api/my-stack/curated/official/alpha"
assert captured["payload"] == {"enabled": False}
assert "DISABLED" in _clean(r.output)