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 from src.schemas import DreamType 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." ) @pytest.mark.asyncio async def test_schedule_dream_card_refresh_forwards_rebuild( client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer], ): """POST /schedule_dream accepts dream_type=card_refresh and forwards the rebuild flag to enqueue_dream (manual/event-driven card refreshes bypass the volume gates by design).""" 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": "card_refresh", "rebuild": True, }, ) assert response.status_code == 204, response.text assert "kwargs" in captured, "enqueue_dream was not called" assert captured["kwargs"]["dream_type"] == DreamType.CARD_REFRESH assert captured["kwargs"]["rebuild"] is True