honcho/tests/routes/test_workspaces.py

761 lines
25 KiB
Python

import datetime
from typing import Any
from unittest.mock import AsyncMock, patch
import pytest
from fastapi.testclient import TestClient
from nanoid import generate as generate_nanoid
from sqlalchemy.ext.asyncio import AsyncSession
from src import models
from src.models import Peer, Workspace
def test_get_or_create_workspace(client: TestClient):
name = str(generate_nanoid())
# This should create the workspace using POST /v3/workspaces
response = client.post("/v3/workspaces", json={"name": name})
assert response.status_code in [200, 201]
data = response.json()
assert data["id"] == name
assert "id" in data
def test_get_or_create_workspace_with_configuration(client: TestClient):
"""Test workspace creation with configuration parameter"""
name = str(generate_nanoid())
configuration = {"feature1": True, "feature2": False}
response = client.post(
"/v3/workspaces", json={"name": name, "configuration": configuration}
)
assert response.status_code in [200, 201]
data = response.json()
assert data["id"] == name
assert data["configuration"] == configuration
def test_get_or_create_workspace_with_all_optional_params(client: TestClient):
"""Test workspace creation with all optional parameters"""
name = str(generate_nanoid())
metadata = {"key": "value", "number": 42}
configuration = {"experimental": True, "beta": False}
response = client.post(
"/v3/workspaces",
json={"name": name, "metadata": metadata, "configuration": configuration},
)
assert response.status_code in [200, 201]
data = response.json()
assert data["id"] == name
assert data["metadata"] == metadata
assert data["configuration"] == configuration
def test_get_or_create_existing_workspace(client: TestClient):
name = str(generate_nanoid())
# Create the workspace
response = client.post(
"/v3/workspaces", json={"name": name, "metadata": {"key": "value"}}
)
assert response.status_code in [200, 201]
workspace1 = response.json()
# Try to create the same workspace again - should return existing workspace
response = client.post(
"/v3/workspaces", json={"name": name, "metadata": {"key": "value"}}
)
assert response.status_code in [200, 201]
workspace2 = response.json()
# Both should be the same workspace
assert workspace1["id"] == workspace2["id"]
assert workspace1["metadata"] == workspace2["metadata"]
@pytest.mark.asyncio
async def test_get_all_workspaces(client: TestClient):
# create a test workspace with metadata
response = client.post(
"/v3/workspaces",
json={
"name": "test_workspace",
"metadata": {"test_key": "test_value"},
},
)
response = client.post(
"/v3/workspaces/list",
json={},
)
assert response.status_code == 200
data = response.json()
assert "items" in data
assert len(data["items"]) > 0
response = client.post(
"/v3/workspaces/list",
json={"filters": {"metadata": {"test_key": "test_value"}}},
)
assert response.status_code == 200
data = response.json()
assert "items" in data
assert len(data["items"]) == 1
assert data["items"][0]["metadata"] == {"test_key": "test_value"}
@pytest.mark.asyncio
async def test_get_all_workspaces_with_empty_filter(client: TestClient):
"""Test workspace listing with empty filter object"""
response = client.post("/v3/workspaces/list", json={"filters": {}})
assert response.status_code == 200
data = response.json()
assert "items" in data
assert isinstance(data["items"], list)
@pytest.mark.asyncio
async def test_get_all_workspaces_with_null_filter(client: TestClient):
"""Test workspace listing with null filter"""
response = client.post("/v3/workspaces/list", json={"filters": None})
assert response.status_code == 200
data = response.json()
assert "items" in data
assert isinstance(data["items"], list)
@pytest.mark.asyncio
async def test_get_all_workspaces_with_reverse(client: TestClient):
"""Test workspace listing with reverse creation-time ordering."""
first_name = f"reverse-workspace-{generate_nanoid()}"
second_name = f"reverse-workspace-{generate_nanoid()}"
first_response = client.post(
"/v3/workspaces",
json={"name": first_name, "metadata": {"reverse_group": first_name}},
)
assert first_response.status_code in [200, 201]
second_response = client.post(
"/v3/workspaces",
json={"name": second_name, "metadata": {"reverse_group": first_name}},
)
assert second_response.status_code in [200, 201]
normal_response = client.post(
"/v3/workspaces/list",
json={"filters": {"metadata": {"reverse_group": first_name}}},
)
assert normal_response.status_code == 200
reverse_response = client.post(
"/v3/workspaces/list?reverse=true",
json={"filters": {"metadata": {"reverse_group": first_name}}},
)
assert reverse_response.status_code == 200
assert [item["id"] for item in normal_response.json()["items"]] == [
first_name,
second_name,
]
assert [item["id"] for item in reverse_response.json()["items"]] == [
second_name,
first_name,
]
@pytest.mark.asyncio
async def test_get_all_workspaces_reverse_uses_id_tiebreaker(
client: TestClient, db_session: AsyncSession
):
"""Workspaces with identical created_at fall back to ordering by id (nanoid PK)."""
reverse_group = f"tiebreaker-{generate_nanoid()}"
shared_created_at = datetime.datetime(
2026, 1, 1, 12, 0, 0, tzinfo=datetime.timezone.utc
)
low_id = "A" * 21
high_id = "z" * 21
low_name = f"tie-low-{generate_nanoid()}"
high_name = f"tie-high-{generate_nanoid()}"
db_session.add(
models.Workspace(
id=low_id,
name=low_name,
created_at=shared_created_at,
h_metadata={"reverse_group": reverse_group},
)
)
db_session.add(
models.Workspace(
id=high_id,
name=high_name,
created_at=shared_created_at,
h_metadata={"reverse_group": reverse_group},
)
)
await db_session.commit()
normal_response = client.post(
"/v3/workspaces/list",
json={"filters": {"metadata": {"reverse_group": reverse_group}}},
)
assert normal_response.status_code == 200
reverse_response = client.post(
"/v3/workspaces/list?reverse=true",
json={"filters": {"metadata": {"reverse_group": reverse_group}}},
)
assert reverse_response.status_code == 200
normal_items = [item["id"] for item in normal_response.json()["items"]]
reverse_items = [item["id"] for item in reverse_response.json()["items"]]
# When created_at ties, ordering falls back to the nanoid id: low_id < high_id
# lexicographically, so the workspace with id="AAA..." sorts first ascending.
assert normal_items == [low_name, high_name]
assert reverse_items == [high_name, low_name]
@pytest.mark.asyncio
async def test_get_all_workspaces_reverse_with_pagination(client: TestClient):
"""Paged reverse listing returns newest-first across consecutive pages."""
reverse_group = f"paged-reverse-{generate_nanoid()}"
names = [f"paged-reverse-{i}-{generate_nanoid()}" for i in range(3)]
for name in names:
response = client.post(
"/v3/workspaces",
json={"name": name, "metadata": {"reverse_group": reverse_group}},
)
assert response.status_code in [200, 201]
page_one = client.post(
"/v3/workspaces/list?reverse=true&page=1&size=1",
json={"filters": {"metadata": {"reverse_group": reverse_group}}},
)
assert page_one.status_code == 200
page_two = client.post(
"/v3/workspaces/list?reverse=true&page=2&size=1",
json={"filters": {"metadata": {"reverse_group": reverse_group}}},
)
assert page_two.status_code == 200
page_three = client.post(
"/v3/workspaces/list?reverse=true&page=3&size=1",
json={"filters": {"metadata": {"reverse_group": reverse_group}}},
)
assert page_three.status_code == 200
assert page_one.json()["total"] == 3
assert [item["id"] for item in page_one.json()["items"]] == [names[2]]
assert [item["id"] for item in page_two.json()["items"]] == [names[1]]
assert [item["id"] for item in page_three.json()["items"]] == [names[0]]
def test_update_workspace(client: TestClient, sample_data: tuple[Workspace, Peer]):
test_workspace, _ = sample_data
_new_name = str(generate_nanoid())
response = client.put(
f"/v3/workspaces/{test_workspace.name}",
json={"metadata": {"new_key": "new_value"}},
)
assert response.status_code == 200
data = response.json()
assert data["metadata"] == {"new_key": "new_value"}
def test_update_workspace_with_configuration(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Test workspace update with configuration parameter"""
test_workspace, _ = sample_data
configuration = {"new_feature": True, "legacy_feature": False}
response = client.put(
f"/v3/workspaces/{test_workspace.name}", json={"configuration": configuration}
)
assert response.status_code == 200
data = response.json()
assert data["configuration"] == configuration
def test_update_workspace_with_all_optional_params(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Test workspace update with both metadata and configuration"""
test_workspace, _ = sample_data
metadata = {"updated_key": "updated_value", "count": 100}
configuration = {"experimental": True, "beta": True}
response = client.put(
f"/v3/workspaces/{test_workspace.name}",
json={"metadata": metadata, "configuration": configuration},
)
assert response.status_code == 200
data = response.json()
assert data["metadata"] == metadata
assert data["configuration"] == configuration
def test_update_workspace_with_null_metadata(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Test workspace update with null metadata (should clear metadata)"""
test_workspace, _ = sample_data
# First set some metadata
client.put(
f"/v3/workspaces/{test_workspace.name}", json={"metadata": {"temp": "value"}}
)
# Then clear it with null
response = client.put(
f"/v3/workspaces/{test_workspace.name}", json={"metadata": None}
)
assert response.status_code == 200
data = response.json()
# The behavior might be to keep existing metadata or clear it -
# adjust this assertion based on actual behavior
assert "metadata" in data
def test_update_workspace_with_null_configuration(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Test workspace update with null configuration"""
test_workspace, _ = sample_data
response = client.put(
f"/v3/workspaces/{test_workspace.name}", json={"configuration": None}
)
assert response.status_code == 200
data = response.json()
assert "configuration" in data
def test_create_duplicate_workspace_name(client: TestClient):
# Create an workspace
name = str(generate_nanoid())
response = client.post("/v3/workspaces", json={"name": name})
assert response.status_code in [200, 201]
# Try to create another workspace with the same name - should return existing workspace
response = client.post("/v3/workspaces", json={"name": name})
# Should return the existing workspace with 200 status (get_or_create behavior)
assert response.status_code in [200, 201]
data = response.json()
assert data["id"] == name
def test_search_workspace(client: TestClient, sample_data: tuple[Workspace, Peer]):
"""Test the workspace search functionality"""
test_workspace, _ = sample_data
# Test search with a query
response = client.post(
f"/v3/workspaces/{test_workspace.name}/search",
json={"query": "test search query", "limit": 10},
)
assert response.status_code == 200
data = response.json()
# Response should be a direct list of messages
assert isinstance(data, list)
def test_search_workspace_empty_query(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Test the workspace search with empty query"""
test_workspace, _ = sample_data
# Test search with empty query
response = client.post(
f"/v3/workspaces/{test_workspace.name}/search", json={"query": "", "limit": 10}
)
assert response.status_code == 200
data = response.json()
# Response should be a direct list of messages
assert isinstance(data, list)
def test_search_workspace_nonexistent(client: TestClient):
"""Test searching a workspace that doesn't exist"""
nonexistent_workspace_id = str(generate_nanoid())
response = client.post(
f"/v3/workspaces/{nonexistent_workspace_id}/search",
json={"query": "test query", "limit": 10},
)
assert response.status_code == 200
data: list[dict[str, Any]] = response.json()
# Should return empty list for nonexistent workspace
assert isinstance(data, list)
assert len(data) == 0
def test_delete_workspace(client: TestClient):
"""Test deleting a workspace"""
name = str(generate_nanoid())
# Create a workspace
response = client.post("/v3/workspaces", json={"name": name})
assert response.status_code in [200, 201]
workspace = response.json()
assert workspace["id"] == name
# Delete the workspace
response = client.delete(f"/v3/workspaces/{name}")
assert response.status_code == 202
# Verify the workspace no longer exists by trying to update it
response = client.put(
f"/v3/workspaces/{name}", json={"metadata": {"test": "value"}}
)
# Should create a new workspace since the old one was deleted
assert response.status_code == 200
def test_delete_nonexistent_workspace(client: TestClient):
"""Test deleting a workspace that doesn't exist"""
nonexistent_workspace_id = str(generate_nanoid())
response = client.delete(f"/v3/workspaces/{nonexistent_workspace_id}")
assert response.status_code == 404
data = response.json()
assert "detail" in data
assert "not found" in data["detail"].lower()
def test_delete_workspace_with_peers(client: TestClient):
"""Test deleting a workspace that has peers"""
workspace_name = str(generate_nanoid())
# Create workspace
response = client.post("/v3/workspaces", json={"name": workspace_name})
assert response.status_code in [200, 201]
# Create peers
peer1_name = str(generate_nanoid())
peer2_name = str(generate_nanoid())
response = client.post(
f"/v3/workspaces/{workspace_name}/peers", json={"name": peer1_name}
)
assert response.status_code in [200, 201]
response = client.post(
f"/v3/workspaces/{workspace_name}/peers", json={"name": peer2_name}
)
assert response.status_code in [200, 201]
# Delete workspace
response = client.delete(f"/v3/workspaces/{workspace_name}")
assert response.status_code == 202
def test_delete_workspace_with_sessions(client: TestClient):
"""Test that deleting a workspace with active sessions returns 409"""
workspace_name = str(generate_nanoid())
# Create workspace
response = client.post("/v3/workspaces", json={"name": workspace_name})
assert response.status_code in [200, 201]
# Create sessions
session1_name = str(generate_nanoid())
session2_name = str(generate_nanoid())
response = client.post(
f"/v3/workspaces/{workspace_name}/sessions", json={"name": session1_name}
)
assert response.status_code in [200, 201]
response = client.post(
f"/v3/workspaces/{workspace_name}/sessions", json={"name": session2_name}
)
assert response.status_code in [200, 201]
# Delete workspace should fail with 409
response = client.delete(f"/v3/workspaces/{workspace_name}")
assert response.status_code == 409
data = response.json()
assert "active session" in data["detail"].lower()
def test_delete_workspace_with_messages(client: TestClient):
"""Test deleting a workspace that has messages"""
workspace_name = str(generate_nanoid())
# Create workspace
response = client.post("/v3/workspaces", json={"name": workspace_name})
assert response.status_code in [200, 201]
# Create peer
peer_name = str(generate_nanoid())
response = client.post(
f"/v3/workspaces/{workspace_name}/peers", json={"name": peer_name}
)
assert response.status_code in [200, 201]
# Create session
session_name = str(generate_nanoid())
response = client.post(
f"/v3/workspaces/{workspace_name}/sessions", json={"name": session_name}
)
assert response.status_code in [200, 201]
# Add peer to session
response = client.post(
f"/v3/workspaces/{workspace_name}/sessions/{session_name}/peers",
json={peer_name: {}},
)
assert response.status_code == 200
# Create messages
response = client.post(
f"/v3/workspaces/{workspace_name}/sessions/{session_name}/messages",
json={
"messages": [
{"content": "Test message 1", "peer_id": peer_name},
{"content": "Test message 2", "peer_id": peer_name},
]
},
)
assert response.status_code == 201
# Delete session first (marks inactive, required before workspace deletion)
response = client.delete(f"/v3/workspaces/{workspace_name}/sessions/{session_name}")
assert response.status_code == 202
# Delete workspace
response = client.delete(f"/v3/workspaces/{workspace_name}")
assert response.status_code == 202
def test_delete_workspace_with_webhooks(client: TestClient):
"""Test deleting a workspace that has webhooks"""
workspace_name = str(generate_nanoid())
# Create workspace
response = client.post("/v3/workspaces", json={"name": workspace_name})
assert response.status_code in [200, 201]
# Create webhook
response = client.post(
f"/v3/workspaces/{workspace_name}/webhooks",
json={
"url": "https://example.com/webhook",
},
)
assert response.status_code in [200, 201]
# Delete workspace
response = client.delete(f"/v3/workspaces/{workspace_name}")
assert response.status_code == 202
# Verify webhook is deleted by checking workspace doesn't exist
response = client.get(f"/v3/workspaces/{workspace_name}/webhooks")
# This should either return 404 or empty list depending on implementation
assert response.status_code in [404, 200]
def test_delete_workspace_cascade(client: TestClient):
"""Test that deleting a workspace cascades to all related resources"""
workspace_name = str(generate_nanoid())
# Create workspace with complex structure
response = client.post(
"/v3/workspaces",
json={"name": workspace_name, "metadata": {"test": "cascade"}},
)
assert response.status_code in [200, 201]
# Create multiple peers
peer_names = [str(generate_nanoid()) for _ in range(3)]
for peer_name in peer_names:
response = client.post(
f"/v3/workspaces/{workspace_name}/peers", json={"name": peer_name}
)
assert response.status_code in [200, 201]
# Create multiple sessions
session_names = [str(generate_nanoid()) for _ in range(2)]
for session_name in session_names:
response = client.post(
f"/v3/workspaces/{workspace_name}/sessions", json={"name": session_name}
)
assert response.status_code in [200, 201]
# Add peers to sessions and create messages
for session_name in session_names:
for peer_name in peer_names[:2]: # Add 2 peers to each session
response = client.post(
f"/v3/workspaces/{workspace_name}/sessions/{session_name}/peers",
json={peer_name: {}},
)
assert response.status_code == 200
# Create messages in session
response = client.post(
f"/v3/workspaces/{workspace_name}/sessions/{session_name}/messages",
json={
"messages": [
{
"content": f"Test message in {session_name}",
"peer_id": peer_names[0],
}
]
},
)
assert response.status_code == 201
# Delete sessions first (marks inactive, required before workspace deletion)
for session_name in session_names:
response = client.delete(
f"/v3/workspaces/{workspace_name}/sessions/{session_name}"
)
assert response.status_code == 202
# Delete the workspace
response = client.delete(f"/v3/workspaces/{workspace_name}")
assert response.status_code == 202
def test_delete_workspace_returns_accepted(client: TestClient):
"""Test that delete workspace returns 202 Accepted"""
name = str(generate_nanoid())
metadata = {"key": "value", "number": 42}
configuration = {"feature": True}
# Create workspace with metadata and configuration
response = client.post(
"/v3/workspaces",
json={"name": name, "metadata": metadata, "configuration": configuration},
)
assert response.status_code in [200, 201]
# Delete workspace
response = client.delete(f"/v3/workspaces/{name}")
assert response.status_code == 202
def test_delete_workspace_blocked_by_sessions_returns_409(client: TestClient):
"""Test that deleting a workspace with active sessions returns 409 with descriptive message"""
workspace_name = str(generate_nanoid())
# Create workspace
response = client.post("/v3/workspaces", json={"name": workspace_name})
assert response.status_code in [200, 201]
# Create a session
session_name = str(generate_nanoid())
response = client.post(
f"/v3/workspaces/{workspace_name}/sessions", json={"name": session_name}
)
assert response.status_code in [200, 201]
# Attempt to delete workspace
response = client.delete(f"/v3/workspaces/{workspace_name}")
assert response.status_code == 409
data = response.json()
assert "detail" in data
assert "active session" in data["detail"]
assert "delete all sessions first" in data["detail"].lower()
def test_delete_workspace_after_session_deletion(client: TestClient):
"""Test that workspace deletion succeeds after all sessions are deleted"""
workspace_name = str(generate_nanoid())
# Create workspace
response = client.post("/v3/workspaces", json={"name": workspace_name})
assert response.status_code in [200, 201]
# Create sessions
session1_name = str(generate_nanoid())
session2_name = str(generate_nanoid())
response = client.post(
f"/v3/workspaces/{workspace_name}/sessions", json={"name": session1_name}
)
assert response.status_code in [200, 201]
response = client.post(
f"/v3/workspaces/{workspace_name}/sessions", json={"name": session2_name}
)
assert response.status_code in [200, 201]
# Workspace deletion should fail
response = client.delete(f"/v3/workspaces/{workspace_name}")
assert response.status_code == 409
# Delete all sessions (marks inactive)
response = client.delete(
f"/v3/workspaces/{workspace_name}/sessions/{session1_name}"
)
assert response.status_code == 202
response = client.delete(
f"/v3/workspaces/{workspace_name}/sessions/{session2_name}"
)
assert response.status_code == 202
# Now workspace deletion should succeed
response = client.delete(f"/v3/workspaces/{workspace_name}")
assert response.status_code == 202
@pytest.mark.asyncio
async def test_schedule_dream_invokes_enqueue_dream(
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""POST /schedule_dream forwards observer/observed/dream_type to enqueue_dream.
After Loop 4, the manual schedule_dream route no longer touches the
baseline count — the orchestrator writes both guard fields atomically on
successful completion. The route's job shrinks to forwarding the dream
request.
"""
workspace, peer = sample_data
collection = models.Collection(
observer=peer.name,
observed=peer.name,
workspace_name=workspace.name,
internal_metadata={},
)
db_session.add(collection)
await db_session.commit()
captured: dict[str, Any] = {}
async def fake_enqueue_dream(*args: Any, **kwargs: Any) -> None:
captured["args"] = args
captured["kwargs"] = kwargs
with (
patch("src.routers.workspaces.settings.DREAM.ENABLED", True),
patch(
"src.routers.workspaces.enqueue_dream",
new=AsyncMock(side_effect=fake_enqueue_dream),
),
):
response = client.post(
f"/v3/workspaces/{workspace.name}/schedule_dream",
json={
"observer": peer.name,
"observed": peer.name,
"dream_type": "omni",
},
)
assert response.status_code == 204, response.text
assert "kwargs" in captured, "enqueue_dream was not called"
assert captured["kwargs"]["observer"] == peer.name
assert captured["kwargs"]["observed"] == peer.name
assert "document_count" not in captured["kwargs"], (
"Loop 4: enqueue_dream no longer accepts document_count; the baseline "
"is written atomically with last_dream_at in process_dream."
)