mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-11 12:17:45 +02:00
[arnav] pytest config, conftest isolation, and API endpoint integration tests
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
"""Shared fixtures for the backend test suite.
|
||||
|
||||
The hard part of testing this app is environment setup — every sub-app
|
||||
reads `OPENSWARM_DATA_DIR` (via `backend.config.paths`) at import time and
|
||||
several lifespans kick off network/IO side effects (PostHog, 9Router,
|
||||
MCP registry refresh). This conftest centralises that boilerplate so a
|
||||
new endpoint test can be a one-liner:
|
||||
|
||||
def test_x(client):
|
||||
r = client.get("/api/x/list")
|
||||
assert r.status_code == 200
|
||||
|
||||
What's done here:
|
||||
|
||||
1. `OPENSWARM_DATA_DIR` is redirected to a fresh tmpdir BEFORE any
|
||||
`backend.*` import. Same trick as the existing stress-test files.
|
||||
2. PostHog is replaced with a Mock so `record(...)` never tries to
|
||||
phone home.
|
||||
3. `nine_router.ensure_running` is no-opped so the analytics lifespan
|
||||
doesn't try to spawn a node subprocess.
|
||||
4. Exposes a `client` fixture that builds a TestClient against the real
|
||||
`backend.main:app`, drives the FastAPI lifespan (which seeds the
|
||||
built-in modes / runs the dashboards migration), and pre-injects
|
||||
the per-install bearer token.
|
||||
5. Opt-in fixtures (`patched_skills_dir`, `stub_agent_loop`) for
|
||||
individual test files that need extra isolation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
from typing import Iterator
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pre-import bootstrap: redirect data dir BEFORE backend.* is imported.
|
||||
# ---------------------------------------------------------------------------
|
||||
# This MUST happen at module load time. backend/config/paths.py snapshots
|
||||
# the env var into module-level constants (DATA_ROOT, SESSIONS_DIR, etc.),
|
||||
# so any backend.* import after this line will pick up our tmp root.
|
||||
|
||||
_TMPROOT = tempfile.mkdtemp(prefix="openswarm-tests-")
|
||||
os.environ["OPENSWARM_DATA_DIR"] = _TMPROOT
|
||||
# Defensive: never let any test run hit the real OAuth helper service.
|
||||
os.environ.setdefault("OPENSWARM_OAUTH_BASE_URL", "http://127.0.0.1:0")
|
||||
|
||||
# Re-home the user. Several agent paths fall back to `~/.openswarm/workspaces/...`
|
||||
# when no default_folder/target_directory is configured (see
|
||||
# `AgentManager.launch_agent`). On a CI runner that's likely fine, but on a
|
||||
# developer machine it would write into the real home directory — and on
|
||||
# sandboxed runs (e.g. agent tests under macOS sandbox) it fails outright with
|
||||
# PermissionError. Pinning HOME inside the tmp root keeps the tests
|
||||
# self-contained AND prevents accidental writes to the user's `~/.claude/skills`.
|
||||
_FAKE_HOME = os.path.join(_TMPROOT, "home")
|
||||
os.makedirs(_FAKE_HOME, exist_ok=True)
|
||||
os.environ["HOME"] = _FAKE_HOME
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mock external services BEFORE backend.main is imported.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _install_external_service_mocks() -> None:
|
||||
"""Patch PostHog + 9Router so lifespans don't make outbound calls.
|
||||
|
||||
Called once at module load. We don't unwind these — they live for
|
||||
the entire pytest session (matching the lifetime of the app fixture).
|
||||
"""
|
||||
# PostHog: replace the class so `Posthog(...)` returns a mock that
|
||||
# silently absorbs `.capture(...)` / `.set(...)` / `.shutdown()`.
|
||||
import posthog
|
||||
_posthog_mock = MagicMock()
|
||||
_posthog_mock.capture = MagicMock()
|
||||
_posthog_mock.set = MagicMock()
|
||||
_posthog_mock.shutdown = MagicMock()
|
||||
posthog.Posthog = MagicMock(return_value=_posthog_mock)
|
||||
|
||||
|
||||
_install_external_service_mocks()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Now safe to import backend.* — paths are pinned to _TMPROOT, posthog
|
||||
# is a mock.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def app():
|
||||
"""The real FastAPI app, lifespan NOT yet run.
|
||||
|
||||
Importing main is what registers all routers and middleware. We
|
||||
additionally patch out the real 9Router and Skills sync triggers
|
||||
that the analytics + settings lifespans spawn.
|
||||
"""
|
||||
# nine_router.ensure_running (called from analytics_lifespan) tries
|
||||
# to pgrep / spawn a Node process. Replace with no-ops, matching
|
||||
# each function's original sync/async shape (mismatch produces a
|
||||
# "coroutine was never awaited" RuntimeWarning).
|
||||
from backend.apps import nine_router as _nine
|
||||
|
||||
async def _async_noop(*args, **kwargs):
|
||||
return None
|
||||
|
||||
def _sync_noop(*args, **kwargs):
|
||||
return None
|
||||
|
||||
_nine.ensure_running = _async_noop # type: ignore[assignment]
|
||||
_nine.stop = _sync_noop # type: ignore[assignment]
|
||||
_nine.sync_gemini_api_key = _async_noop # type: ignore[assignment]
|
||||
_nine.sync_openswarm_pro_as_claude = _async_noop # type: ignore[assignment]
|
||||
|
||||
from backend.main import app as _app
|
||||
|
||||
return _app
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def auth_token() -> str:
|
||||
"""Initialise the per-install bearer token once for the session."""
|
||||
from backend.auth import get_auth_token, init_auth_token
|
||||
|
||||
token = get_auth_token() or init_auth_token()
|
||||
return token
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def auth_headers(auth_token: str) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {auth_token}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_data_dirs(monkeypatch) -> Iterator[str]:
|
||||
"""Wipe + recreate every per-feature data dir before each test.
|
||||
|
||||
Keeps tests order-independent. We don't move DATA_ROOT itself
|
||||
(paths.py snapshotted that at import time); we just clear the
|
||||
children so a previous test's mode/dashboard/tool/etc files don't
|
||||
leak into the next.
|
||||
"""
|
||||
from backend.config import paths as _p
|
||||
|
||||
targets = [
|
||||
_p.SESSIONS_DIR,
|
||||
_p.MODES_DIR,
|
||||
_p.DASHBOARDS_DIR,
|
||||
_p.SETTINGS_DIR,
|
||||
_p.TOOLS_DIR,
|
||||
_p.OUTPUTS_DIR,
|
||||
_p.OUTPUTS_WORKSPACE_DIR,
|
||||
_p.SKILLS_WORKSPACE_DIR,
|
||||
_p.DASHBOARD_LAYOUT_DIR,
|
||||
]
|
||||
for d in targets:
|
||||
if os.path.exists(d):
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
os.makedirs(d, exist_ok=True)
|
||||
|
||||
# Drop in-memory agent_manager state that survived a prior test.
|
||||
try:
|
||||
from backend.apps.agents.agent_manager import agent_manager as _am
|
||||
|
||||
_am.sessions.clear()
|
||||
_am.tasks.clear()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Drop in-memory seq_log state too. Session ids are UUIDs so cross-test
|
||||
# collisions are vanishingly rare, but a leaked ring buffer between
|
||||
# tests can still produce confusing replay results in any test that
|
||||
# inspects seq_log directly.
|
||||
try:
|
||||
from backend.apps.agents.seq_log import seq_log as _sl
|
||||
|
||||
_sl._per_session.clear()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
yield _p.DATA_ROOT
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(app, auth_headers, tmp_data_dirs):
|
||||
"""A pre-authenticated TestClient with FastAPI lifespan running.
|
||||
|
||||
The `with` block triggers startup events — this is what seeds the
|
||||
built-in modes (modes_lifespan) and runs the dashboards migration
|
||||
(dashboards_lifespan). Without it the modes endpoint would return
|
||||
an empty list and the dashboards list would be empty too.
|
||||
"""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
# Per-test: seed the built-ins manually too. Lifespan only seeds
|
||||
# them if the directory is empty, which `tmp_data_dirs` guarantees.
|
||||
with TestClient(app) as tc:
|
||||
tc.headers.update(auth_headers)
|
||||
yield tc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Opt-in fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def patched_skills_dir(monkeypatch, tmp_path) -> str:
|
||||
"""Repoint backend.apps.skills at a tmp dir.
|
||||
|
||||
The real SKILLS_DIR lives at `~/.claude/skills` and is shared with
|
||||
the user's actual Claude Code install. Tests must never touch it.
|
||||
Hard-asserts that the patched path is inside the test tmpdir before
|
||||
yielding.
|
||||
"""
|
||||
from backend.apps.skills import skills as skills_mod
|
||||
|
||||
skills_root = str(tmp_path / "skills")
|
||||
os.makedirs(skills_root, exist_ok=True)
|
||||
|
||||
# Sanity gate — fail fast if pytest's tmp_path is somehow ~/.claude.
|
||||
real_skills = os.path.expanduser("~/.claude/skills")
|
||||
assert os.path.abspath(skills_root) != os.path.abspath(real_skills), (
|
||||
"patched_skills_dir would clobber the real ~/.claude/skills"
|
||||
)
|
||||
|
||||
monkeypatch.setattr(skills_mod, "SKILLS_DIR", skills_root)
|
||||
monkeypatch.setattr(
|
||||
skills_mod, "INDEX_PATH", os.path.join(skills_root, ".skills_index.json")
|
||||
)
|
||||
return skills_root
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stub_agent_loop(monkeypatch):
|
||||
"""Replace AgentManager._run_agent_loop with a no-op coroutine.
|
||||
|
||||
Lets routes that internally `asyncio.create_task(self._run_agent_loop(...))`
|
||||
succeed without spawning the Claude Code CLI / making real model calls.
|
||||
"""
|
||||
from backend.apps.agents.agent_manager import AgentManager
|
||||
|
||||
async def _noop(self, session_id, prompt, *args, **kwargs):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(AgentManager, "_run_agent_loop", _noop)
|
||||
@@ -0,0 +1,550 @@
|
||||
"""REST-surface smoke tests for /api/agents.
|
||||
|
||||
Tests:
|
||||
- Auth: a request without the bearer token returns 401 (positive
|
||||
control on the middleware).
|
||||
- GET /sessions returns an empty list on a clean root.
|
||||
- POST /launch creates a session; subsequent GET /sessions/{id}
|
||||
succeeds; DELETE removes it.
|
||||
- POST /sessions/{id}/edit_message validates input.
|
||||
- PATCH /sessions/{id} updates allowed fields (name, system_prompt).
|
||||
- GET /sessions/{id}/branches returns the default 'main' branch.
|
||||
- GET /history returns paginated results.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def test_protected_route_requires_auth(app, tmp_data_dirs):
|
||||
"""Positive control: hitting an agents route without auth → 401."""
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
with TestClient(app) as tc:
|
||||
# No Authorization header.
|
||||
resp = tc.get("/api/agents/sessions")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_list_sessions_empty(client):
|
||||
resp = client.get("/api/agents/sessions")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"sessions": []}
|
||||
|
||||
|
||||
def test_get_unknown_session_returns_404(client):
|
||||
resp = client.get("/api/agents/sessions/does-not-exist")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_launch_get_delete_session(client, stub_agent_loop):
|
||||
"""End-to-end REST round-trip for a session.
|
||||
|
||||
Uses stub_agent_loop defensively even though `launch` itself
|
||||
doesn't kick the loop — keeps the test stable if the launch path
|
||||
is ever refactored to start streaming immediately.
|
||||
"""
|
||||
launch = client.post(
|
||||
"/api/agents/launch",
|
||||
json={
|
||||
"name": "Smoke Agent",
|
||||
"model": "sonnet",
|
||||
"mode": "agent",
|
||||
"provider": "anthropic",
|
||||
},
|
||||
)
|
||||
assert launch.status_code == 200, launch.text
|
||||
session_id = launch.json()["session_id"]
|
||||
|
||||
fetched = client.get(f"/api/agents/sessions/{session_id}")
|
||||
assert fetched.status_code == 200
|
||||
body = fetched.json()
|
||||
assert body["id"] == session_id
|
||||
assert body["name"] == "Smoke Agent"
|
||||
|
||||
listed = client.get("/api/agents/sessions").json()["sessions"]
|
||||
assert any(s["id"] == session_id for s in listed)
|
||||
|
||||
deleted = client.delete(f"/api/agents/sessions/{session_id}")
|
||||
assert deleted.status_code == 200
|
||||
|
||||
gone = client.get(f"/api/agents/sessions/{session_id}")
|
||||
assert gone.status_code == 404
|
||||
|
||||
|
||||
def test_send_message_requires_prompt(client, stub_agent_loop):
|
||||
launch = client.post(
|
||||
"/api/agents/launch",
|
||||
json={"name": "X", "model": "sonnet", "mode": "agent"},
|
||||
)
|
||||
session_id = launch.json()["session_id"]
|
||||
|
||||
resp = client.post(
|
||||
f"/api/agents/sessions/{session_id}/message",
|
||||
json={"prompt": ""},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_edit_message_requires_id_and_content(client, stub_agent_loop):
|
||||
launch = client.post(
|
||||
"/api/agents/launch",
|
||||
json={"name": "X", "model": "sonnet", "mode": "agent"},
|
||||
)
|
||||
session_id = launch.json()["session_id"]
|
||||
|
||||
missing_id = client.post(
|
||||
f"/api/agents/sessions/{session_id}/edit_message",
|
||||
json={"content": "hi"},
|
||||
)
|
||||
assert missing_id.status_code == 400
|
||||
|
||||
missing_content = client.post(
|
||||
f"/api/agents/sessions/{session_id}/edit_message",
|
||||
json={"message_id": "abc"},
|
||||
)
|
||||
assert missing_content.status_code == 400
|
||||
|
||||
|
||||
def test_patch_session_updates_name(client, stub_agent_loop):
|
||||
"""PATCH /sessions/{id} only mutates the allowlist {name,
|
||||
system_prompt, thinking_level}. Anything else is silently ignored
|
||||
in `update_session` — tested implicitly by the round-trip below."""
|
||||
launch = client.post(
|
||||
"/api/agents/launch",
|
||||
json={"name": "Original", "model": "sonnet", "mode": "agent"},
|
||||
)
|
||||
session_id = launch.json()["session_id"]
|
||||
|
||||
resp = client.patch(
|
||||
f"/api/agents/sessions/{session_id}",
|
||||
json={"name": "Renamed", "model": "ignored-because-not-allowed"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
fetched = client.get(f"/api/agents/sessions/{session_id}").json()
|
||||
assert fetched["name"] == "Renamed"
|
||||
assert fetched["model"] == "sonnet" # not changed by the PATCH
|
||||
|
||||
|
||||
def test_get_branches_returns_main(client, stub_agent_loop):
|
||||
launch = client.post(
|
||||
"/api/agents/launch",
|
||||
json={"name": "B", "model": "sonnet", "mode": "agent"},
|
||||
)
|
||||
session_id = launch.json()["session_id"]
|
||||
|
||||
resp = client.get(f"/api/agents/sessions/{session_id}/branches")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["active_branch_id"] == "main"
|
||||
assert "main" in body["branches"]
|
||||
|
||||
|
||||
def test_history_endpoint_returns_paginated_shape(client):
|
||||
"""`/history` is the search-and-resume endpoint. Even with no
|
||||
saved sessions it should return the paginated wrapper without
|
||||
error."""
|
||||
resp = client.get("/api/agents/history", params={"q": "", "limit": 5, "offset": 0})
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
# We don't pin the exact key set here (it grows over time); just
|
||||
# that the response is a JSON object with a list-of-things in it.
|
||||
assert isinstance(body, dict)
|
||||
# The paginated wrapper does have stable keys though — assert them so
|
||||
# we catch accidental reshapes that would break the frontend's
|
||||
# history drawer.
|
||||
assert "sessions" in body and isinstance(body["sessions"], list)
|
||||
assert "total" in body
|
||||
assert "has_more" in body
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle: stop / close / resume / duplicate / switch_branch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _launch(client, **overrides) -> str:
|
||||
"""Helper: launch a session and return its id."""
|
||||
payload = {"name": "T", "model": "sonnet", "mode": "agent"}
|
||||
payload.update(overrides)
|
||||
resp = client.post("/api/agents/launch", json=payload)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()["session_id"]
|
||||
|
||||
|
||||
def test_stop_agent_marks_session_stopped(client, stub_agent_loop):
|
||||
"""POST /sessions/{id}/stop transitions status to 'stopped'.
|
||||
|
||||
The route is idempotent on a freshly-launched session that has no
|
||||
running task — `stop_agent` no-ops on the task side and still flips
|
||||
the status field.
|
||||
"""
|
||||
session_id = _launch(client)
|
||||
|
||||
resp = client.post(f"/api/agents/sessions/{session_id}/stop")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"ok": True}
|
||||
|
||||
fetched = client.get(f"/api/agents/sessions/{session_id}").json()
|
||||
assert fetched["status"] == "stopped"
|
||||
|
||||
|
||||
def test_close_session_removes_from_active_and_lands_in_history(client, stub_agent_loop):
|
||||
"""`/close` is the soft-delete path: persists the session JSON to
|
||||
disk, drops it from in-memory, and the `/history` endpoint should
|
||||
serve it back. Distinct from DELETE which is a hard purge."""
|
||||
session_id = _launch(client, name="To-Close")
|
||||
|
||||
resp = client.post(f"/api/agents/sessions/{session_id}/close")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"ok": True}
|
||||
|
||||
listed = client.get("/api/agents/sessions").json()["sessions"]
|
||||
assert all(s["id"] != session_id for s in listed)
|
||||
|
||||
history = client.get("/api/agents/history").json()
|
||||
assert any(item["id"] == session_id for item in history["sessions"])
|
||||
|
||||
|
||||
def test_close_unknown_session_returns_404(client):
|
||||
resp = client.post("/api/agents/sessions/does-not-exist/close")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_resume_session_restores_to_active(client, stub_agent_loop):
|
||||
"""Round-trip: launch → close → resume → session is active again
|
||||
(in-memory) and gone from history."""
|
||||
session_id = _launch(client, name="To-Resume")
|
||||
|
||||
close = client.post(f"/api/agents/sessions/{session_id}/close")
|
||||
assert close.status_code == 200
|
||||
|
||||
resume = client.post(f"/api/agents/sessions/{session_id}/resume")
|
||||
assert resume.status_code == 200
|
||||
body = resume.json()
|
||||
assert body["session"]["id"] == session_id
|
||||
|
||||
fetched = client.get(f"/api/agents/sessions/{session_id}")
|
||||
assert fetched.status_code == 200
|
||||
|
||||
# `resume_session` deletes the on-disk file, so the entry should no
|
||||
# longer appear in history.
|
||||
history = client.get("/api/agents/history").json()
|
||||
assert all(item["id"] != session_id for item in history["sessions"])
|
||||
|
||||
|
||||
def test_resume_unknown_session_returns_404(client):
|
||||
resp = client.post("/api/agents/sessions/does-not-exist/resume")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_duplicate_session_returns_new_session(client, stub_agent_loop):
|
||||
"""Duplicate forks the chat history into a new session id. The
|
||||
original must still be reachable; the copy gets ` (copy)` appended
|
||||
to the name."""
|
||||
original_id = _launch(client, name="Original")
|
||||
|
||||
resp = client.post(f"/api/agents/sessions/{original_id}/duplicate", json={})
|
||||
assert resp.status_code == 200
|
||||
new_session = resp.json()["session"]
|
||||
assert new_session["id"] != original_id
|
||||
assert new_session["name"].endswith("(copy)")
|
||||
|
||||
listed_ids = {s["id"] for s in client.get("/api/agents/sessions").json()["sessions"]}
|
||||
assert original_id in listed_ids
|
||||
assert new_session["id"] in listed_ids
|
||||
|
||||
|
||||
def test_switch_branch_validation(client, stub_agent_loop):
|
||||
"""Empty `branch_id` → 400; switching to the default `main` branch
|
||||
that always exists → 200."""
|
||||
session_id = _launch(client)
|
||||
|
||||
missing = client.post(
|
||||
f"/api/agents/sessions/{session_id}/switch_branch",
|
||||
json={},
|
||||
)
|
||||
assert missing.status_code == 400
|
||||
|
||||
ok = client.post(
|
||||
f"/api/agents/sessions/{session_id}/switch_branch",
|
||||
json={"branch_id": "main"},
|
||||
)
|
||||
assert ok.status_code == 200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes defined directly on `app` in main.py: /compact and /clear
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_session_compact_returns_status(client, stub_agent_loop):
|
||||
"""`/compact` is a programmatic summarisation pass — no LLM call.
|
||||
On a session with < 4 messages it short-circuits with `compacted=False`
|
||||
but still returns 200."""
|
||||
session_id = _launch(client)
|
||||
|
||||
resp = client.post(f"/api/agents/sessions/{session_id}/compact")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert "compacted" in body
|
||||
assert body["compacted"] is False # short prompt, nothing to compact
|
||||
|
||||
|
||||
def test_session_compact_unknown_returns_404(client):
|
||||
resp = client.post("/api/agents/sessions/does-not-exist/compact")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_session_clear_resets_sdk_state(client, stub_agent_loop):
|
||||
"""`/clear` keeps `messages` but mints a new sdk_session_id and
|
||||
resets MCPs/outputs/tokens/cost. We assert the response shape and
|
||||
that the session-level fields snap back to defaults."""
|
||||
session_id = _launch(client)
|
||||
|
||||
resp = client.post(f"/api/agents/sessions/{session_id}/clear")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"cleared": True}
|
||||
|
||||
fetched = client.get(f"/api/agents/sessions/{session_id}").json()
|
||||
assert fetched["sdk_session_id"] is None
|
||||
assert fetched["active_mcps"] == []
|
||||
assert fetched["active_outputs"] == []
|
||||
assert fetched["tokens"] == {"input": 0, "output": 0}
|
||||
assert fetched["cost_usd"] == 0.0
|
||||
|
||||
|
||||
def test_session_clear_unknown_returns_404(client):
|
||||
resp = client.post("/api/agents/sessions/does-not-exist/clear")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Read-only metadata endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_browser_agent_children_empty_for_fresh_session(client, stub_agent_loop):
|
||||
session_id = _launch(client)
|
||||
resp = client.get(f"/api/agents/sessions/{session_id}/browser-agents")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"sessions": []}
|
||||
|
||||
|
||||
def test_list_models_returns_envelope(client):
|
||||
"""`GET /models` returns `{"models": <dict>, "notes": <list>}`. With
|
||||
no API keys configured and 9Router down (the test environment), the
|
||||
`models` dict can legitimately be empty — the contract is the
|
||||
envelope, not the contents."""
|
||||
resp = client.get("/api/agents/models")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert isinstance(body.get("models"), dict)
|
||||
assert isinstance(body.get("notes"), list)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validation-only: generate-title, generate-group-meta, approval
|
||||
#
|
||||
# These routes' happy paths fan out to Anthropic / WebSocket-resolved
|
||||
# events; we deliberately stop at "the route rejects bad input" so the
|
||||
# tests stay hermetic. End-to-end coverage lives elsewhere.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_generate_title_requires_prompt(client, stub_agent_loop):
|
||||
session_id = _launch(client)
|
||||
resp = client.post(
|
||||
f"/api/agents/sessions/{session_id}/generate-title",
|
||||
json={"prompt": ""},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_generate_group_meta_requires_group_id_and_tool_calls(client, stub_agent_loop):
|
||||
session_id = _launch(client)
|
||||
|
||||
missing_group = client.post(
|
||||
f"/api/agents/sessions/{session_id}/generate-group-meta",
|
||||
json={"tool_calls": [{"tool": "Bash"}]},
|
||||
)
|
||||
assert missing_group.status_code == 400
|
||||
|
||||
missing_calls = client.post(
|
||||
f"/api/agents/sessions/{session_id}/generate-group-meta",
|
||||
json={"group_id": "g1", "tool_calls": []},
|
||||
)
|
||||
assert missing_calls.status_code == 400
|
||||
|
||||
|
||||
def test_approval_pydantic_validation(client):
|
||||
"""`/approval` is the only agents route gated by a Pydantic model —
|
||||
Pydantic returns 422 (not 400) on validation errors. We test all
|
||||
three failure modes plus a well-formed body that just no-ops because
|
||||
the request_id has no live waiter (handle_approval is best-effort)."""
|
||||
empty = client.post("/api/agents/approval", json={})
|
||||
assert empty.status_code == 422
|
||||
|
||||
missing_behavior = client.post(
|
||||
"/api/agents/approval",
|
||||
json={"request_id": "abc"},
|
||||
)
|
||||
assert missing_behavior.status_code == 422
|
||||
|
||||
bad_behavior = client.post(
|
||||
"/api/agents/approval",
|
||||
json={"request_id": "abc", "behavior": "maybe"},
|
||||
)
|
||||
assert bad_behavior.status_code == 422
|
||||
|
||||
well_formed = client.post(
|
||||
"/api/agents/approval",
|
||||
json={"request_id": "abc", "behavior": "deny"},
|
||||
)
|
||||
assert well_formed.status_code == 200
|
||||
assert well_formed.json() == {"ok": True}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Query parameters / launch field round-trip
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_launch_round_trips_optional_fields(client, stub_agent_loop, tmp_path):
|
||||
"""Launch with the full optional surface and assert each field
|
||||
that's actually preserved comes back out via GET.
|
||||
|
||||
Note: `allowed_tools` is intentionally NOT round-tripped — the
|
||||
launch path ignores `config.allowed_tools` and resolves the tool
|
||||
set from the mode definition (`_resolve_mode`). The caller-supplied
|
||||
list is dropped on the floor; what ends up on the session is the
|
||||
mode's tool roster. See agent_manager.launch_agent for the source.
|
||||
"""
|
||||
target = str(tmp_path / "workdir")
|
||||
import os as _os
|
||||
_os.makedirs(target, exist_ok=True)
|
||||
|
||||
launch = client.post(
|
||||
"/api/agents/launch",
|
||||
json={
|
||||
"name": "Full",
|
||||
"model": "sonnet",
|
||||
"mode": "agent",
|
||||
"system_prompt": "be concise",
|
||||
"target_directory": target,
|
||||
"dashboard_id": "dash-test",
|
||||
},
|
||||
)
|
||||
assert launch.status_code == 200, launch.text
|
||||
session_id = launch.json()["session_id"]
|
||||
|
||||
body = client.get(f"/api/agents/sessions/{session_id}").json()
|
||||
assert body["system_prompt"] == "be concise"
|
||||
assert body["dashboard_id"] == "dash-test"
|
||||
assert body["cwd"] == target
|
||||
# Mode-resolved tools are non-empty for the default "agent" mode.
|
||||
assert isinstance(body["allowed_tools"], list)
|
||||
assert len(body["allowed_tools"]) > 0
|
||||
|
||||
|
||||
def test_list_sessions_dashboard_filter(client, stub_agent_loop):
|
||||
"""`?dashboard_id=` scopes the list to one dashboard. Sessions
|
||||
without a dashboard never leak into a filtered list."""
|
||||
no_dash_id = _launch(client, name="Loose")
|
||||
in_dash_id = _launch(client, name="Pinned", dashboard_id="dash-A")
|
||||
|
||||
all_sessions = client.get("/api/agents/sessions").json()["sessions"]
|
||||
assert {s["id"] for s in all_sessions} >= {no_dash_id, in_dash_id}
|
||||
|
||||
filtered = client.get(
|
||||
"/api/agents/sessions",
|
||||
params={"dashboard_id": "dash-A"},
|
||||
).json()["sessions"]
|
||||
filtered_ids = {s["id"] for s in filtered}
|
||||
assert in_dash_id in filtered_ids
|
||||
assert no_dash_id not in filtered_ids
|
||||
|
||||
|
||||
def test_history_pagination_and_search(client, stub_agent_loop):
|
||||
"""Close three sessions and exercise q / limit / offset.
|
||||
|
||||
The search index is built from `name + message content`; with no
|
||||
messages, only `name` is indexable.
|
||||
"""
|
||||
ids = [
|
||||
_launch(client, name=f"alpha-{i}") for i in range(3)
|
||||
]
|
||||
for sid in ids:
|
||||
client.post(f"/api/agents/sessions/{sid}/close").raise_for_status()
|
||||
|
||||
# No filter, limit=2 → first page returns 2, has_more=True.
|
||||
page1 = client.get(
|
||||
"/api/agents/history",
|
||||
params={"limit": 2, "offset": 0},
|
||||
).json()
|
||||
assert page1["total"] == 3
|
||||
assert len(page1["sessions"]) == 2
|
||||
assert page1["has_more"] is True
|
||||
|
||||
# Offset to the tail.
|
||||
page2 = client.get(
|
||||
"/api/agents/history",
|
||||
params={"limit": 2, "offset": 2},
|
||||
).json()
|
||||
assert len(page2["sessions"]) == 1
|
||||
assert page2["has_more"] is False
|
||||
|
||||
# Search restricts. "alpha" matches all three by name.
|
||||
matched = client.get(
|
||||
"/api/agents/history",
|
||||
params={"q": "alpha"},
|
||||
).json()
|
||||
assert matched["total"] == 3
|
||||
|
||||
# An obviously-absent token returns nothing.
|
||||
none = client.get(
|
||||
"/api/agents/history",
|
||||
params={"q": "zzzz-no-match-zzzz"},
|
||||
).json()
|
||||
assert none["total"] == 0
|
||||
assert none["sessions"] == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Expanded PATCH coverage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_patch_session_updates_system_prompt(client, stub_agent_loop):
|
||||
session_id = _launch(client)
|
||||
resp = client.patch(
|
||||
f"/api/agents/sessions/{session_id}",
|
||||
json={"system_prompt": "you are a helpful otter"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = client.get(f"/api/agents/sessions/{session_id}").json()
|
||||
assert body["system_prompt"] == "you are a helpful otter"
|
||||
|
||||
|
||||
def test_patch_session_updates_thinking_level(client, stub_agent_loop):
|
||||
"""`thinking_level` accepts an enum {off, low, medium, high, auto}.
|
||||
Garbage values are silently ignored by `update_session` — assert
|
||||
that round-trip behaviour."""
|
||||
session_id = _launch(client)
|
||||
|
||||
accepted = client.patch(
|
||||
f"/api/agents/sessions/{session_id}",
|
||||
json={"thinking_level": "high"},
|
||||
)
|
||||
assert accepted.status_code == 200
|
||||
assert client.get(f"/api/agents/sessions/{session_id}").json()["thinking_level"] == "high"
|
||||
|
||||
# Bogus enum value: the route still returns 200 (silently ignored)
|
||||
# and the previous value is preserved.
|
||||
rejected = client.patch(
|
||||
f"/api/agents/sessions/{session_id}",
|
||||
json={"thinking_level": "extreme"},
|
||||
)
|
||||
assert rejected.status_code == 200
|
||||
assert client.get(f"/api/agents/sessions/{session_id}").json()["thinking_level"] == "high"
|
||||
@@ -0,0 +1,229 @@
|
||||
"""REST-surface tests for /api/agents/subscriptions.
|
||||
|
||||
These routes wrap 9Router (the bundled OAuth/proxy daemon). The
|
||||
`backend.tests.conftest` already no-ops `nine_router.ensure_running` so
|
||||
no node subprocess is spawned, but `nine_router.is_running()` still tries
|
||||
a real `httpx.get` to localhost:20128 and we don't want any network
|
||||
chatter from these tests. Two fixtures here:
|
||||
|
||||
- `nine_router_down` — pins is_running() to False.
|
||||
- `nine_router_up` — pins is_running() to True and stubs the four
|
||||
async helpers (get_providers, get_models,
|
||||
start_oauth, poll_oauth, exchange_oauth) with
|
||||
canned dicts.
|
||||
|
||||
Coverage:
|
||||
|
||||
- GET /subscriptions/status — both down + up shapes.
|
||||
- GET /subscriptions/models — both down + up shapes.
|
||||
- POST /subscriptions/connect — missing provider → 400; 503 when
|
||||
9Router is unavailable; happy path
|
||||
round-trips the device_code flow.
|
||||
- POST /subscriptions/poll — missing provider / device_code → 400.
|
||||
- POST /subscriptions/exchange — missing provider / code → 400.
|
||||
- POST /subscriptions/disconnect — missing provider → 400.
|
||||
|
||||
Happy paths for poll / exchange / disconnect involve writing real
|
||||
provider connection state via 9Router and are out of scope for the REST
|
||||
surface tests; they're covered by the integration suite.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def nine_router_down(monkeypatch):
|
||||
"""Pin nine_router.is_running() to False.
|
||||
|
||||
Defends against the cached True branch in the real implementation
|
||||
(see _IS_RUNNING_TTL in backend/apps/nine_router.py) leaking from
|
||||
other test runs.
|
||||
"""
|
||||
import backend.apps.nine_router as _nr
|
||||
|
||||
monkeypatch.setattr(_nr, "is_running", lambda: False)
|
||||
# ensure_running is also stubbed in conftest, but re-stubbing here
|
||||
# makes the contract of this fixture self-evident.
|
||||
async def _async_noop(*args, **kwargs):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(_nr, "ensure_running", _async_noop)
|
||||
return _nr
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def nine_router_up(monkeypatch):
|
||||
"""Pretend 9Router is running and serve canned data.
|
||||
|
||||
All async helpers used by the subscriptions routes are replaced.
|
||||
Tests can override individual helpers via `monkeypatch.setattr`
|
||||
after this fixture runs.
|
||||
"""
|
||||
import backend.apps.nine_router as _nr
|
||||
|
||||
monkeypatch.setattr(_nr, "is_running", lambda: True)
|
||||
|
||||
async def _providers():
|
||||
return [
|
||||
{"provider": "claude", "id": "conn-1", "isActive": True},
|
||||
]
|
||||
|
||||
async def _models():
|
||||
return [{"id": "claude-sonnet-4", "owned_by": "anthropic"}]
|
||||
|
||||
async def _start_oauth(provider: str):
|
||||
return {
|
||||
"flow": "device_code",
|
||||
"user_code": "ABCD-1234",
|
||||
"verification_uri": "https://example.com/activate",
|
||||
"device_code": "dev-code-xyz",
|
||||
"code_verifier": "",
|
||||
"extra_data": {},
|
||||
}
|
||||
|
||||
async def _poll_oauth(provider, device_code, **_):
|
||||
return {"success": True, "connection": {"provider": provider, "id": "conn-1"}}
|
||||
|
||||
async def _exchange_oauth(provider, code, redirect_uri, code_verifier, state=""):
|
||||
return {"success": True, "connection": {"provider": provider, "id": "conn-1"}}
|
||||
|
||||
monkeypatch.setattr(_nr, "get_providers", _providers)
|
||||
monkeypatch.setattr(_nr, "get_models", _models)
|
||||
monkeypatch.setattr(_nr, "start_oauth", _start_oauth)
|
||||
monkeypatch.setattr(_nr, "poll_oauth", _poll_oauth)
|
||||
monkeypatch.setattr(_nr, "exchange_oauth", _exchange_oauth)
|
||||
return _nr
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /subscriptions/status
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_status_when_9router_down(client, nine_router_down):
|
||||
resp = client.get("/api/agents/subscriptions/status")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"running": False, "providers": [], "models": []}
|
||||
|
||||
|
||||
def test_status_when_9router_up(client, nine_router_up):
|
||||
"""When up, the route wraps providers in a `connections` envelope —
|
||||
the OnboardingModal and Settings UI both read `data.providers.connections`.
|
||||
Pin that shape so an accidental rename surfaces as a test failure."""
|
||||
resp = client.get("/api/agents/subscriptions/status")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["running"] is True
|
||||
assert "connections" in body["providers"]
|
||||
assert isinstance(body["providers"]["connections"], list)
|
||||
assert isinstance(body["models"], list)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /subscriptions/models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_models_when_9router_down(client, nine_router_down):
|
||||
resp = client.get("/api/agents/subscriptions/models")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"models": []}
|
||||
|
||||
|
||||
def test_models_when_9router_up(client, nine_router_up):
|
||||
resp = client.get("/api/agents/subscriptions/models")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert isinstance(body["models"], list)
|
||||
assert len(body["models"]) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /subscriptions/connect
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_connect_requires_provider(client, nine_router_down):
|
||||
resp = client.post("/api/agents/subscriptions/connect", json={})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_connect_returns_503_when_9router_unavailable(client, nine_router_down):
|
||||
"""Provider given but 9Router can't be started — the route returns
|
||||
503 with a Node-install hint. `nine_router_down` pins both
|
||||
is_running() AND ensure_running() so the route's retry path
|
||||
short-circuits."""
|
||||
resp = client.post(
|
||||
"/api/agents/subscriptions/connect",
|
||||
json={"provider": "claude"},
|
||||
)
|
||||
assert resp.status_code == 503
|
||||
|
||||
|
||||
def test_connect_round_trips_device_code(client, nine_router_up):
|
||||
resp = client.post(
|
||||
"/api/agents/subscriptions/connect",
|
||||
json={"provider": "github"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["flow"] == "device_code"
|
||||
assert body["user_code"] == "ABCD-1234"
|
||||
assert body["device_code"] == "dev-code-xyz"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /subscriptions/poll
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_poll_requires_provider_and_device_code(client):
|
||||
"""Both fields must be present. We test each missing-half separately
|
||||
so a regression that only validates one of them is caught."""
|
||||
no_provider = client.post(
|
||||
"/api/agents/subscriptions/poll",
|
||||
json={"device_code": "dev"},
|
||||
)
|
||||
assert no_provider.status_code == 400
|
||||
|
||||
no_device = client.post(
|
||||
"/api/agents/subscriptions/poll",
|
||||
json={"provider": "claude"},
|
||||
)
|
||||
assert no_device.status_code == 400
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /subscriptions/exchange
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_exchange_requires_provider_and_code(client):
|
||||
no_provider = client.post(
|
||||
"/api/agents/subscriptions/exchange",
|
||||
json={"code": "abc"},
|
||||
)
|
||||
assert no_provider.status_code == 400
|
||||
|
||||
no_code = client.post(
|
||||
"/api/agents/subscriptions/exchange",
|
||||
json={"provider": "claude"},
|
||||
)
|
||||
assert no_code.status_code == 400
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /subscriptions/disconnect
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_disconnect_requires_provider(client):
|
||||
resp = client.post("/api/agents/subscriptions/disconnect", json={})
|
||||
assert resp.status_code == 400
|
||||
@@ -0,0 +1,156 @@
|
||||
"""CRUD smoke for /api/dashboards.
|
||||
|
||||
Dashboards are the spatial canvas units. Each holds a `DashboardLayout`
|
||||
(positions for agent/view/browser cards + sticky notes). Sessions are
|
||||
tagged with `dashboard_id` so deleting a dashboard cascades into its
|
||||
sessions.
|
||||
|
||||
Tests:
|
||||
- lifespan migration creates "Dashboard 1" on a fresh boot
|
||||
- create / get / update (name + layout) / duplicate / delete round-trip
|
||||
- delete cascades into SESSIONS_DIR (sessions tagged with the
|
||||
dashboard id are removed from disk)
|
||||
- seed-demo lays down a session JSON with two messages
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
|
||||
def test_lifespan_seeds_default_dashboard(client):
|
||||
"""First boot with no existing dashboards creates 'Dashboard 1'."""
|
||||
resp = client.get("/api/dashboards/list")
|
||||
assert resp.status_code == 200
|
||||
items = resp.json()["dashboards"]
|
||||
assert len(items) >= 1
|
||||
assert any(d["name"] == "Dashboard 1" for d in items)
|
||||
|
||||
|
||||
def test_create_get_update_delete_dashboard(client):
|
||||
create = client.post(
|
||||
"/api/dashboards/create",
|
||||
json={"name": "Project Alpha"},
|
||||
)
|
||||
assert create.status_code == 200
|
||||
dashboard_id = create.json()["id"]
|
||||
|
||||
fetched = client.get(f"/api/dashboards/{dashboard_id}")
|
||||
assert fetched.status_code == 200
|
||||
assert fetched.json()["name"] == "Project Alpha"
|
||||
|
||||
# Renaming flips auto_named → False (per dashboards.py:275).
|
||||
update = client.put(
|
||||
f"/api/dashboards/{dashboard_id}",
|
||||
json={"name": "Project Beta"},
|
||||
)
|
||||
assert update.status_code == 200
|
||||
body = update.json()
|
||||
assert body["name"] == "Project Beta"
|
||||
assert body["auto_named"] is False
|
||||
|
||||
deleted = client.delete(f"/api/dashboards/{dashboard_id}")
|
||||
assert deleted.status_code == 200
|
||||
|
||||
gone = client.get(f"/api/dashboards/{dashboard_id}")
|
||||
assert gone.status_code == 404
|
||||
|
||||
|
||||
def test_dashboard_layout_round_trip(client):
|
||||
"""PUT layout with a sticky note and assert it survives a re-fetch."""
|
||||
create = client.post("/api/dashboards/create", json={"name": "L"})
|
||||
dashboard_id = create.json()["id"]
|
||||
|
||||
layout = {
|
||||
"cards": {},
|
||||
"view_cards": {},
|
||||
"browser_cards": {},
|
||||
"notes": {
|
||||
"n1": {
|
||||
"note_id": "n1",
|
||||
"x": 100,
|
||||
"y": 200,
|
||||
"width": 240,
|
||||
"height": 200,
|
||||
"content": "test note",
|
||||
"color": "yellow",
|
||||
}
|
||||
},
|
||||
"expanded_session_ids": [],
|
||||
}
|
||||
resp = client.put(
|
||||
f"/api/dashboards/{dashboard_id}",
|
||||
json={"layout": layout},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
refetched = client.get(f"/api/dashboards/{dashboard_id}")
|
||||
assert refetched.json()["layout"]["notes"]["n1"]["content"] == "test note"
|
||||
|
||||
|
||||
def test_duplicate_dashboard(client):
|
||||
create = client.post("/api/dashboards/create", json={"name": "Source"})
|
||||
dashboard_id = create.json()["id"]
|
||||
|
||||
dup = client.post(f"/api/dashboards/{dashboard_id}/duplicate")
|
||||
assert dup.status_code == 200
|
||||
body = dup.json()
|
||||
assert body["id"] != dashboard_id
|
||||
assert body["name"] == "Source (copy)"
|
||||
|
||||
|
||||
def test_delete_dashboard_cascades_to_sessions(client):
|
||||
"""Sessions tagged with dashboard_id must be removed on delete.
|
||||
|
||||
Drops a fake session file straight onto SESSIONS_DIR (no agent
|
||||
spawn needed) and verifies the cascade in `delete_dashboard`.
|
||||
"""
|
||||
from backend.config.paths import SESSIONS_DIR
|
||||
|
||||
create = client.post("/api/dashboards/create", json={"name": "Cascade"})
|
||||
dashboard_id = create.json()["id"]
|
||||
|
||||
os.makedirs(SESSIONS_DIR, exist_ok=True)
|
||||
session_id = "fake-session-cascade"
|
||||
session_path = os.path.join(SESSIONS_DIR, f"{session_id}.json")
|
||||
with open(session_path, "w") as f:
|
||||
json.dump(
|
||||
{
|
||||
"id": session_id,
|
||||
"name": "junk",
|
||||
"dashboard_id": dashboard_id,
|
||||
"messages": [],
|
||||
"branches": {},
|
||||
"active_branch_id": "main",
|
||||
"created_at": "2026-01-01T00:00:00",
|
||||
},
|
||||
f,
|
||||
)
|
||||
assert os.path.exists(session_path)
|
||||
|
||||
deleted = client.delete(f"/api/dashboards/{dashboard_id}")
|
||||
assert deleted.status_code == 200
|
||||
|
||||
assert not os.path.exists(session_path), (
|
||||
"session file tagged with deleted dashboard should be removed"
|
||||
)
|
||||
|
||||
|
||||
def test_seed_demo_creates_session(client):
|
||||
from backend.config.paths import SESSIONS_DIR
|
||||
|
||||
create = client.post("/api/dashboards/create", json={"name": "Demo"})
|
||||
dashboard_id = create.json()["id"]
|
||||
|
||||
resp = client.post(f"/api/dashboards/{dashboard_id}/seed-demo")
|
||||
assert resp.status_code == 200
|
||||
session_id = resp.json()["session_id"]
|
||||
|
||||
session_path = os.path.join(SESSIONS_DIR, f"{session_id}.json")
|
||||
assert os.path.exists(session_path)
|
||||
|
||||
with open(session_path) as f:
|
||||
data = json.load(f)
|
||||
assert data["dashboard_id"] == dashboard_id
|
||||
assert len(data["messages"]) == 2 # canned welcome conversation
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Health endpoint contract.
|
||||
|
||||
The Electron main process polls /api/health/check before it has the
|
||||
auth token (the HTTP port is up before main.js calls loadAuthToken()).
|
||||
Two invariants this test pins down:
|
||||
|
||||
1. The path is auth-exempt — Electron's pre-token poll succeeds.
|
||||
2. The response is plain-text "OK" — required by AWS ALB health
|
||||
checks and (more practically) Electron's boot handshake which
|
||||
reads the body literally instead of parsing JSON.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def test_health_check_returns_plain_ok(client):
|
||||
resp = client.get("/api/health/check")
|
||||
assert resp.status_code == 200
|
||||
assert resp.text == "OK"
|
||||
assert resp.headers["content-type"].startswith("text/plain")
|
||||
|
||||
|
||||
def test_health_check_does_not_require_auth(app, tmp_data_dirs):
|
||||
"""Hit /api/health/check WITHOUT the bearer header.
|
||||
|
||||
Uses a fresh TestClient (the shared `client` fixture pre-injects
|
||||
auth headers, which would mask a regression in the exempt-list).
|
||||
"""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
with TestClient(app) as tc:
|
||||
resp = tc.get("/api/health/check")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.text == "OK"
|
||||
@@ -0,0 +1,105 @@
|
||||
"""CRUD smoke for /api/modes.
|
||||
|
||||
Modes are the per-agent system-prompt + tool-allowlist presets. The
|
||||
five built-ins (`agent`, `ask`, `plan`, `view-builder`, `skill-builder`)
|
||||
are seeded from `BUILTIN_MODES` on lifespan start; user-defined modes
|
||||
live alongside them on disk under `MODES_DIR`.
|
||||
|
||||
Tests:
|
||||
- list returns the seeded built-ins
|
||||
- create/get/update/delete round-trip a custom mode
|
||||
- reset on a built-in restores defaults
|
||||
- delete on a built-in returns 403 (per the explicit guard in modes.py)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
BUILTIN_IDS = {"agent", "ask", "plan", "view-builder", "skill-builder"}
|
||||
|
||||
|
||||
def test_list_returns_builtin_modes(client):
|
||||
resp = client.get("/api/modes/list")
|
||||
assert resp.status_code == 200
|
||||
payload = resp.json()
|
||||
assert "modes" in payload and "builtin_defaults" in payload
|
||||
|
||||
seen_ids = {m["id"] for m in payload["modes"]}
|
||||
assert BUILTIN_IDS.issubset(seen_ids), (
|
||||
f"expected built-ins {BUILTIN_IDS}, saw {seen_ids}"
|
||||
)
|
||||
|
||||
# builtin_defaults is the source of truth the frontend uses to
|
||||
# render "Reset to defaults" — must match exactly.
|
||||
assert set(payload["builtin_defaults"].keys()) == BUILTIN_IDS
|
||||
|
||||
|
||||
def test_get_specific_builtin_mode(client):
|
||||
resp = client.get("/api/modes/agent")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["id"] == "agent"
|
||||
assert body["is_builtin"] is True
|
||||
|
||||
|
||||
def test_create_update_delete_custom_mode(client):
|
||||
create = client.post(
|
||||
"/api/modes/create",
|
||||
json={
|
||||
"name": "Test Mode",
|
||||
"description": "smoke",
|
||||
"system_prompt": "You are a test.",
|
||||
"tools": ["Read", "Grep"],
|
||||
},
|
||||
)
|
||||
assert create.status_code == 200, create.text
|
||||
mode_id = create.json()["mode"]["id"]
|
||||
assert mode_id not in BUILTIN_IDS
|
||||
|
||||
fetched = client.get(f"/api/modes/{mode_id}")
|
||||
assert fetched.status_code == 200
|
||||
assert fetched.json()["name"] == "Test Mode"
|
||||
assert fetched.json()["is_builtin"] is False
|
||||
|
||||
update = client.put(
|
||||
f"/api/modes/{mode_id}",
|
||||
json={"description": "smoke v2"},
|
||||
)
|
||||
assert update.status_code == 200
|
||||
assert update.json()["mode"]["description"] == "smoke v2"
|
||||
assert update.json()["mode"]["name"] == "Test Mode" # unchanged
|
||||
|
||||
deleted = client.delete(f"/api/modes/{mode_id}")
|
||||
assert deleted.status_code == 200
|
||||
|
||||
gone = client.get(f"/api/modes/{mode_id}")
|
||||
assert gone.status_code == 404
|
||||
|
||||
|
||||
def test_cannot_delete_builtin_mode(client):
|
||||
"""Built-ins are sticky — a stray UI delete must not nuke them."""
|
||||
resp = client.delete("/api/modes/agent")
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_reset_builtin_mode_restores_defaults(client):
|
||||
"""Mutate `agent` then reset; system_prompt should snap back."""
|
||||
update = client.put(
|
||||
"/api/modes/agent",
|
||||
json={"system_prompt": "MUTATED FOR TEST"},
|
||||
)
|
||||
assert update.status_code == 200
|
||||
assert update.json()["mode"]["system_prompt"] == "MUTATED FOR TEST"
|
||||
|
||||
reset = client.post("/api/modes/agent/reset")
|
||||
assert reset.status_code == 200
|
||||
assert reset.json()["mode"]["system_prompt"] != "MUTATED FOR TEST"
|
||||
|
||||
|
||||
def test_reset_only_works_on_builtins(client):
|
||||
"""Custom (non-builtin) modes can't be reset — there's no default."""
|
||||
create = client.post("/api/modes/create", json={"name": "X"})
|
||||
mode_id = create.json()["mode"]["id"]
|
||||
|
||||
resp = client.post(f"/api/modes/{mode_id}/reset")
|
||||
assert resp.status_code == 400
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Smoke tests for /api/settings.
|
||||
|
||||
Settings are the single AppSettings document persisted at
|
||||
`<DATA_ROOT>/settings/settings.json`. The PUT handler also fans out to
|
||||
9Router sync (mocked in conftest) and PostHog analytics, neither of
|
||||
which we exercise here.
|
||||
|
||||
Tests:
|
||||
- GET /api/settings returns defaults including the canned system prompt
|
||||
- PUT /api/settings round-trips a value and survives `load_settings()` reload
|
||||
- default-system-prompt + reset-system-prompt
|
||||
- browse-directories on a tmp dir
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
|
||||
def test_get_returns_defaults(client):
|
||||
resp = client.get("/api/settings")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["default_model"] == "sonnet"
|
||||
assert body["default_mode"] == "agent"
|
||||
assert body["default_system_prompt"] # populated from DEFAULT_SYSTEM_PROMPT
|
||||
|
||||
|
||||
def test_put_round_trips_value(client):
|
||||
"""PUT a sentinel theme; GET must echo it back, AND the on-disk
|
||||
record must reflect it (catches accidental write-only-in-memory
|
||||
regressions in `save_settings_async`)."""
|
||||
current = client.get("/api/settings").json()
|
||||
current["theme"] = "light"
|
||||
|
||||
put = client.put("/api/settings", json=current)
|
||||
assert put.status_code == 200
|
||||
assert put.json()["settings"]["theme"] == "light"
|
||||
|
||||
refetched = client.get("/api/settings").json()
|
||||
assert refetched["theme"] == "light"
|
||||
|
||||
# And confirm the persistence layer (not just the in-memory cache).
|
||||
from backend.apps.settings.settings import load_settings
|
||||
|
||||
assert load_settings().theme == "light"
|
||||
|
||||
|
||||
def test_default_system_prompt_returns_constant(client):
|
||||
from backend.apps.settings.models import DEFAULT_SYSTEM_PROMPT
|
||||
|
||||
resp = client.get("/api/settings/default-system-prompt")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["default_system_prompt"] == DEFAULT_SYSTEM_PROMPT
|
||||
|
||||
|
||||
def test_reset_system_prompt(client):
|
||||
"""Mutate then reset; the prompt must snap back to DEFAULT_SYSTEM_PROMPT."""
|
||||
from backend.apps.settings.models import DEFAULT_SYSTEM_PROMPT
|
||||
|
||||
current = client.get("/api/settings").json()
|
||||
current["default_system_prompt"] = "MUTATED"
|
||||
client.put("/api/settings", json=current)
|
||||
|
||||
resp = client.post("/api/settings/reset-system-prompt")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["settings"]["default_system_prompt"] == DEFAULT_SYSTEM_PROMPT
|
||||
|
||||
|
||||
def test_browse_directories_lists_tmp(client, tmp_path):
|
||||
"""Drop a known file/dir into pytest's tmp_path and assert it shows up."""
|
||||
(tmp_path / "subdir").mkdir()
|
||||
(tmp_path / "hello.txt").write_text("hi")
|
||||
|
||||
resp = client.get(
|
||||
"/api/settings/browse-directories",
|
||||
params={"path": str(tmp_path)},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["current"] == os.path.abspath(str(tmp_path))
|
||||
assert "subdir" in body["directories"]
|
||||
assert "hello.txt" in body["files"]
|
||||
|
||||
|
||||
def test_browse_directories_404_on_missing_path(client, tmp_path):
|
||||
resp = client.get(
|
||||
"/api/settings/browse-directories",
|
||||
params={"path": str(tmp_path / "does-not-exist")},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Smoke tests for /api/skills.
|
||||
|
||||
Skills are SKILL.md files synced into `~/.claude/skills`. Tests MUST
|
||||
NEVER touch the user's real skills dir, so this whole module relies on
|
||||
the `patched_skills_dir` fixture.
|
||||
|
||||
Tests:
|
||||
- empty list on a fresh dir
|
||||
- dropping a SKILL.md into the patched dir surfaces in /list
|
||||
- create / get / update / delete round-trip
|
||||
- workspace/seed writes SKILL.md and meta.json
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
|
||||
def test_list_empty_on_fresh_dir(client, patched_skills_dir):
|
||||
resp = client.get("/api/skills/list")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"skills": []}
|
||||
|
||||
|
||||
def test_list_picks_up_dropped_skill(client, patched_skills_dir):
|
||||
"""Filesystem-driven discovery — dropping a .md file is enough."""
|
||||
skill_path = os.path.join(patched_skills_dir, "test-skill.md")
|
||||
with open(skill_path, "w") as f:
|
||||
f.write("# Test Skill\n\nBody content.\n")
|
||||
|
||||
resp = client.get("/api/skills/list")
|
||||
assert resp.status_code == 200
|
||||
skills = resp.json()["skills"]
|
||||
assert any(s["id"] == "test-skill" for s in skills)
|
||||
|
||||
|
||||
def test_create_get_update_delete_skill(client, patched_skills_dir):
|
||||
create = client.post(
|
||||
"/api/skills/create",
|
||||
json={
|
||||
"name": "Smoke",
|
||||
"description": "smoke skill",
|
||||
"content": "---\nname: smoke\ndescription: a smoke skill\n---\n\nBody.",
|
||||
},
|
||||
)
|
||||
assert create.status_code == 200, create.text
|
||||
skill_id = create.json()["skill"]["id"]
|
||||
|
||||
fetched = client.get(f"/api/skills/{skill_id}")
|
||||
assert fetched.status_code == 200
|
||||
assert fetched.json()["name"] == "Smoke"
|
||||
|
||||
update = client.put(
|
||||
f"/api/skills/{skill_id}",
|
||||
json={"description": "smoke v2"},
|
||||
)
|
||||
assert update.status_code == 200
|
||||
assert update.json()["skill"]["description"] == "smoke v2"
|
||||
|
||||
deleted = client.delete(f"/api/skills/{skill_id}")
|
||||
assert deleted.status_code == 200
|
||||
assert not os.path.exists(os.path.join(patched_skills_dir, f"{skill_id}.md"))
|
||||
|
||||
|
||||
def test_workspace_seed_writes_files(client, patched_skills_dir):
|
||||
"""seed_skill_workspace writes SKILL.md + meta.json under
|
||||
SKILLS_WORKSPACE_DIR (patched at import time via OPENSWARM_DATA_DIR)."""
|
||||
from backend.config.paths import SKILLS_WORKSPACE_DIR
|
||||
|
||||
workspace_id = "test-workspace"
|
||||
resp = client.post(
|
||||
"/api/skills/workspace/seed",
|
||||
json={
|
||||
"workspace_id": workspace_id,
|
||||
"skill_content": "---\nname: x\n---\nbody",
|
||||
"meta": {"name": "X", "description": "y"},
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
folder = os.path.join(SKILLS_WORKSPACE_DIR, workspace_id)
|
||||
assert os.path.isfile(os.path.join(folder, "SKILL.md"))
|
||||
|
||||
with open(os.path.join(folder, "meta.json")) as f:
|
||||
meta = json.load(f)
|
||||
assert meta["name"] == "X"
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Smoke tests for /api/tools.
|
||||
|
||||
Tools are MCP server definitions (stdio/HTTP/SSE) plus their connection
|
||||
state. Built-ins (Read/Edit/Write/etc.) are defined statically in
|
||||
`BUILTIN_TOOLS`; user-installed MCPs are persisted as JSON under
|
||||
TOOLS_DIR.
|
||||
|
||||
OAuth flows + MCP discovery require external services and are out of
|
||||
scope for this pass — see plan.
|
||||
|
||||
Tests:
|
||||
- GET /api/tools/builtin returns the static built-in list
|
||||
- GET /api/tools/list returns user-installed defs (empty by default)
|
||||
- GET/PUT /api/tools/builtin/permissions round-trips
|
||||
- create / get / update / delete CRUD
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def test_list_builtin_tools(client):
|
||||
"""The static BUILTIN_TOOLS list — must include the always-loaded core."""
|
||||
resp = client.get("/api/tools/builtin")
|
||||
assert resp.status_code == 200
|
||||
names = {t["name"] for t in resp.json()["tools"]}
|
||||
# Sanity check on the core tools every agent gets by default.
|
||||
assert {"Read", "Edit", "Write", "Bash", "Grep", "Glob"}.issubset(names)
|
||||
|
||||
|
||||
def test_list_user_tools_empty_by_default(client):
|
||||
resp = client.get("/api/tools/list")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"tools": []}
|
||||
|
||||
|
||||
def test_create_get_update_delete_tool(client):
|
||||
create = client.post(
|
||||
"/api/tools/create",
|
||||
json={
|
||||
"name": "TestMCP",
|
||||
"description": "smoke MCP server",
|
||||
"command": "echo hello",
|
||||
"mcp_config": {
|
||||
"type": "stdio",
|
||||
"command": "echo",
|
||||
"args": ["hello"],
|
||||
},
|
||||
"auth_type": "none",
|
||||
"auth_status": "none",
|
||||
},
|
||||
)
|
||||
assert create.status_code == 200, create.text
|
||||
tool_id = create.json()["tool"]["id"]
|
||||
assert tool_id
|
||||
|
||||
fetched = client.get(f"/api/tools/{tool_id}")
|
||||
assert fetched.status_code == 200
|
||||
assert fetched.json()["name"] == "TestMCP"
|
||||
|
||||
update = client.put(
|
||||
f"/api/tools/{tool_id}",
|
||||
json={"description": "updated"},
|
||||
)
|
||||
assert update.status_code == 200
|
||||
assert update.json()["tool"]["description"] == "updated"
|
||||
assert update.json()["tool"]["name"] == "TestMCP" # unchanged
|
||||
|
||||
listed = client.get("/api/tools/list").json()["tools"]
|
||||
assert any(t["id"] == tool_id for t in listed)
|
||||
|
||||
deleted = client.delete(f"/api/tools/{tool_id}")
|
||||
assert deleted.status_code == 200
|
||||
assert all(t["id"] != tool_id for t in client.get("/api/tools/list").json()["tools"])
|
||||
|
||||
|
||||
def test_builtin_permissions_round_trip(client):
|
||||
"""The user can override per-tool default permissions for built-ins.
|
||||
|
||||
The PUT handler is allowlist-gated: only known tool names + valid
|
||||
policies (`always_allow`/`ask`/`deny`) survive the round-trip. The
|
||||
body shape is `{"permissions": {...}}` per `update_builtin_permissions`.
|
||||
"""
|
||||
update = client.put(
|
||||
"/api/tools/builtin/permissions",
|
||||
json={"permissions": {"Bash": "ask"}},
|
||||
)
|
||||
assert update.status_code == 200
|
||||
|
||||
refetched = client.get("/api/tools/builtin/permissions").json()["permissions"]
|
||||
assert refetched["Bash"] == "ask"
|
||||
|
||||
|
||||
def test_builtin_permissions_rejects_unknown_tool_or_policy(client):
|
||||
"""Garbage input is silently dropped, not 4xx'd. Verify it doesn't
|
||||
poison the persisted map."""
|
||||
resp = client.put(
|
||||
"/api/tools/builtin/permissions",
|
||||
json={"permissions": {
|
||||
"BogusTool": "ask", # unknown tool name
|
||||
"Bash": "shrug", # unknown policy
|
||||
}},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
perms = resp.json()["permissions"]
|
||||
assert "BogusTool" not in perms
|
||||
assert perms.get("Bash") != "shrug"
|
||||
@@ -0,0 +1,59 @@
|
||||
"""App-level smoke test.
|
||||
|
||||
Goal: catch the failure mode where adding a new sub-app silently breaks
|
||||
imports, or a refactor accidentally drops a router from the
|
||||
`MainApp([...])` registration list in `backend/main.py`. Hits no business
|
||||
logic — just verifies the FastAPI app boots and its OpenAPI surface
|
||||
contains the routes we expect every release to ship.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def test_app_module_imports():
|
||||
"""Importing backend.main must not raise.
|
||||
|
||||
A regression in any sub-app module would surface here as an
|
||||
ImportError during collection. Cheap canary worth keeping
|
||||
independent of the other tests.
|
||||
"""
|
||||
from backend.main import app
|
||||
|
||||
assert app is not None
|
||||
|
||||
|
||||
def test_openapi_lists_expected_routes(client):
|
||||
"""Each long-lived sub-app's flagship route must appear in /openapi.json.
|
||||
|
||||
These are intentionally one-per-router so adding/removing an
|
||||
individual endpoint doesn't churn the test, but accidentally
|
||||
dropping an entire sub-app from `MainApp([...])` breaks loudly.
|
||||
"""
|
||||
resp = client.get("/openapi.json")
|
||||
assert resp.status_code == 200
|
||||
|
||||
paths = set(resp.json()["paths"].keys())
|
||||
expected = {
|
||||
"/api/health/check",
|
||||
"/api/modes/list",
|
||||
"/api/dashboards/list",
|
||||
"/api/agents/sessions",
|
||||
"/api/settings",
|
||||
"/api/tools/list",
|
||||
"/api/skills/list",
|
||||
"/api/outputs/list",
|
||||
}
|
||||
missing = expected - paths
|
||||
assert not missing, f"Routes missing from /openapi.json: {missing}"
|
||||
|
||||
|
||||
def test_app_has_cors_and_auth_middleware(app):
|
||||
"""Defensive: regression-guard the security posture of the app.
|
||||
|
||||
The CORS + auth middlewares are added unconditionally in main.py.
|
||||
If a refactor ever drops one, this test catches it before it ships.
|
||||
"""
|
||||
middleware_names = [m.cls.__name__ for m in app.user_middleware]
|
||||
assert any("CORS" in n for n in middleware_names), (
|
||||
f"CORSMiddleware missing from app — saw {middleware_names}"
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
[pytest]
|
||||
# Pytest invoked from project root (see scripts/test.sh and the GitHub
|
||||
# Actions workflow). Keeping the ini here makes that the rootdir so
|
||||
# `from backend.apps...` imports resolve without sys.path tweaks.
|
||||
testpaths = backend/tests
|
||||
asyncio_mode = auto
|
||||
filterwarnings =
|
||||
ignore::DeprecationWarning
|
||||
ignore::PendingDeprecationWarning
|
||||
Reference in New Issue
Block a user