import datetime 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.config import settings from src.models import Peer, Workspace from src.security import JWTParams, create_jwt @pytest.mark.asyncio async def test_create_message( client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer] ): test_workspace, test_peer = sample_data # Create a test session test_session = models.Session( workspace_name=test_workspace.name, name=str(generate_nanoid()) ) db_session.add(test_session) await db_session.commit() response = client.post( f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages", json={ "messages": [ { "content": "Test message", "peer_id": test_peer.name, "metadata": {"message_key": "message_value"}, } ] }, ) assert response.status_code == 201 data = response.json() assert len(data) == 1 message = data[0] assert message["content"] == "Test message" assert message["peer_id"] == test_peer.name assert message["session_id"] == test_session.name assert message["metadata"] == {"message_key": "message_value"} assert "id" in message @pytest.mark.asyncio async def test_create_message_schedules_immediate_embed( client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer] ): """Creating messages should schedule the immediate-embed background task with the created messages' public ids.""" test_workspace, test_peer = sample_data test_session = models.Session( workspace_name=test_workspace.name, name=str(generate_nanoid()) ) db_session.add(test_session) await db_session.commit() with ( patch("src.config.settings.EMBED_MESSAGES", True), patch.object(settings.EMBEDDING, "MAX_PENDING_EMBED_TASKS", 50), patch( "src.reconciler.embed_now.embed_messages_now", new=AsyncMock() ) as mock_embed_now, ): response = client.post( f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages", json={"messages": [{"content": "hello", "peer_id": test_peer.name}]}, ) assert response.status_code == 201 public_id = response.json()[0]["id"] mock_embed_now.assert_awaited_once_with([public_id]) @pytest.mark.asyncio async def test_create_message_skips_embed_when_disabled( client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer] ): """When EMBED_MESSAGES is disabled, the immediate-embed task is not scheduled.""" test_workspace, test_peer = sample_data test_session = models.Session( workspace_name=test_workspace.name, name=str(generate_nanoid()) ) db_session.add(test_session) await db_session.commit() with ( patch("src.config.settings.EMBED_MESSAGES", False), patch( "src.reconciler.embed_now.embed_messages_now", new=AsyncMock() ) as mock_embed_now, ): response = client.post( f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages", json={"messages": [{"content": "hello", "peer_id": test_peer.name}]}, ) assert response.status_code == 201 mock_embed_now.assert_not_called() @pytest.mark.asyncio async def test_create_message_defers_embed_when_saturated( client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer] ): """When the immediate-embed task cap is saturated, message creation still succeeds and no embed task runs — rows stay pending for the reconciler.""" test_workspace, test_peer = sample_data test_session = models.Session( workspace_name=test_workspace.name, name=str(generate_nanoid()) ) db_session.add(test_session) await db_session.commit() with ( patch("src.config.settings.EMBED_MESSAGES", True), patch.object(settings.EMBEDDING, "MAX_PENDING_EMBED_TASKS", 0), patch( "src.reconciler.embed_now.embed_messages_now", new=AsyncMock() ) as mock_embed_now, ): response = client.post( f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages", json={"messages": [{"content": "hello", "peer_id": test_peer.name}]}, ) assert response.status_code == 201 mock_embed_now.assert_not_called() @pytest.mark.asyncio async def test_file_upload_schedules_immediate_embed( client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer] ): """The file-upload path schedules the immediate-embed task with the created messages' public ids, mirroring the session-message path.""" import io test_workspace, test_peer = sample_data test_session = models.Session( workspace_name=test_workspace.name, name=str(generate_nanoid()) ) db_session.add(test_session) await db_session.commit() with ( patch("src.config.settings.EMBED_MESSAGES", True), patch.object(settings.EMBEDDING, "MAX_PENDING_EMBED_TASKS", 50), patch( "src.reconciler.embed_now.embed_messages_now", new=AsyncMock() ) as mock_embed_now, ): files = {"file": ("note.txt", io.BytesIO(b"hello world"), "text/plain")} response = client.post( f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/upload", files=files, data={"peer_id": test_peer.name}, ) assert response.status_code == 201 expected_ids = [m["id"] for m in response.json()] mock_embed_now.assert_awaited_once_with(expected_ids) @pytest.mark.asyncio async def test_create_batch_messages_with_metadata( client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer] ): """Test batch message creation with metadata for each message""" test_workspace, test_peer = sample_data # Create a test session test_session = models.Session( workspace_name=test_workspace.name, name=str(generate_nanoid()) ) db_session.add(test_session) await db_session.commit() response = client.post( f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages", json={ "messages": [ { "content": "Message 1", "peer_id": test_peer.name, "metadata": {"type": "question", "priority": "high"}, }, { "content": "Message 2", "peer_id": test_peer.name, "metadata": {"type": "answer", "priority": "low"}, }, ] }, ) assert response.status_code == 201 data = response.json() assert len(data) == 2 # Check first message assert data[0]["content"] == "Message 1" assert data[0]["metadata"] == {"type": "question", "priority": "high"} # Check second message assert data[1]["content"] == "Message 2" assert data[1]["metadata"] == {"type": "answer", "priority": "low"} @pytest.mark.asyncio async def test_create_batch_messages_without_metadata( client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer] ): """Test batch message creation without metadata (should default to empty dict)""" test_workspace, test_peer = sample_data # Create a test session test_session = models.Session( workspace_name=test_workspace.name, name=str(generate_nanoid()) ) db_session.add(test_session) await db_session.commit() response = client.post( f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages", json={ "messages": [ { "content": "Message without metadata", "peer_id": test_peer.name, } ] }, ) assert response.status_code == 201 data = response.json() assert len(data) == 1 assert data[0]["content"] == "Message without metadata" assert data[0]["metadata"] == {} @pytest.mark.asyncio async def test_create_batch_messages_with_null_metadata( client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer] ): """Test batch message creation with null metadata""" test_workspace, test_peer = sample_data # Create a test session test_session = models.Session( workspace_name=test_workspace.name, name=str(generate_nanoid()) ) db_session.add(test_session) await db_session.commit() response = client.post( f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages", json={ "messages": [ { "content": "Message with null metadata", "peer_id": test_peer.name, "metadata": None, } ] }, ) assert response.status_code == 201 data = response.json() assert len(data) == 1 assert data[0]["content"] == "Message with null metadata" assert data[0]["metadata"] == {} @pytest.mark.asyncio async def test_get_messages( client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer] ): test_workspace, test_peer = sample_data # Create a test session and message test_session = models.Session( workspace_name=test_workspace.name, name=str(generate_nanoid()) ) db_session.add(test_session) await db_session.commit() test_message = models.Message( session_name=test_session.name, content="Test message", workspace_name=test_workspace.name, peer_name=test_peer.name, seq_in_session=1, ) db_session.add(test_message) await db_session.commit() response = client.post( f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/list", json={}, ) assert response.status_code == 200 data = response.json() assert "items" in data assert len(data["items"]) > 0 assert data["items"][0]["content"] == "Test message" assert data["items"][0]["peer_id"] == test_peer.name assert data["items"][0]["session_id"] == test_session.name assert data["items"][0]["metadata"] == {} @pytest.mark.asyncio async def test_member_peer_key_reads_session_but_cannot_write( client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer], monkeypatch: pytest.MonkeyPatch, ): """A peer-scoped key may read sessions its peer belongs to (membership-based cross-scope read), but not write to them. Non-member peer keys and session keys on peer routes are denied. Exercises the real session_peers lookup.""" test_workspace, alice = sample_data session_name = str(generate_nanoid()) base = f"/v3/workspaces/{test_workspace.name}/sessions/{session_name}" # Setup with auth disabled: create the session with alice as an active # member, then commit so the independent tracked_db session in auth() (which # only sees committed rows) can resolve membership. client.post(f"{base}/peers", json={alice.name: {}}) await db_session.commit() # Enforce auth for the assertions below. monkeypatch.setattr(settings.AUTH, "USE_AUTH", True) monkeypatch.setattr(settings.AUTH, "JWT_SECRET", "test-secret") # Member peer key: reads allowed. client.headers["Authorization"] = ( f"Bearer {create_jwt(JWTParams(w=test_workspace.name, p=alice.name))}" ) assert client.post(f"{base}/messages/list", json={}).status_code == 200 assert client.get(f"{base}/context").status_code == 200 # Member peer key: writes denied (write routes don't opt into member read). assert ( client.post( f"{base}/messages", json={"messages": [{"content": "nope", "peer_id": alice.name}]}, ).status_code == 401 ) # Non-member peer key: even reads denied. client.headers["Authorization"] = ( f"Bearer {create_jwt(JWTParams(w=test_workspace.name, p='not-a-member'))}" ) assert client.post(f"{base}/messages/list", json={}).status_code == 401 # Session key: no cross-scope access to peer routes. client.headers["Authorization"] = ( f"Bearer {create_jwt(JWTParams(w=test_workspace.name, s=session_name))}" ) assert ( client.get( f"/v3/workspaces/{test_workspace.name}/peers/{alice.name}/card" ).status_code == 401 ) @pytest.mark.asyncio async def test_member_peer_key_reads_only_own_session_peer_config( client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer], monkeypatch: pytest.MonkeyPatch, ): """A member peer key may read its OWN per-session config but not a co-member's. The route opts into member read, so without the in-handler self-check alice could read bob's config.""" test_workspace, alice = sample_data bob_name = str(generate_nanoid()) session_name = str(generate_nanoid()) base = f"/v3/workspaces/{test_workspace.name}/sessions/{session_name}" # Create the session with alice and bob as active members; commit so the # independent read-only tracked_db in auth() can resolve membership. client.post(f"{base}/peers", json={alice.name: {}, bob_name: {}}) await db_session.commit() monkeypatch.setattr(settings.AUTH, "USE_AUTH", True) monkeypatch.setattr(settings.AUTH, "JWT_SECRET", "test-secret") client.headers["Authorization"] = ( f"Bearer {create_jwt(JWTParams(w=test_workspace.name, p=alice.name))}" ) # Own config: allowed. assert client.get(f"{base}/peers/{alice.name}/config").status_code == 200 # Co-member's config: denied even though alice is a session member. assert client.get(f"{base}/peers/{bob_name}/config").status_code == 401 @pytest.mark.asyncio async def test_get_messages_with_reverse( client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer] ): """Test getting messages with reverse parameter""" test_workspace, test_peer = sample_data # Create a test session test_session = models.Session( workspace_name=test_workspace.name, name=str(generate_nanoid()) ) db_session.add(test_session) await db_session.commit() # Create multiple messages to test ordering test_message1 = models.Message( session_name=test_session.name, content="First message", workspace_name=test_workspace.name, peer_name=test_peer.name, seq_in_session=1, ) test_message2 = models.Message( session_name=test_session.name, content="Second message", workspace_name=test_workspace.name, peer_name=test_peer.name, seq_in_session=2, ) db_session.add(test_message1) db_session.add(test_message2) await db_session.commit() # Test normal order response = client.post( f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/list", json={}, ) assert response.status_code == 200 normal_data = response.json() # Test reversed order response = client.post( f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/list?reverse=true", json={}, ) assert response.status_code == 200 reversed_data = response.json() # Both should have items assert len(normal_data["items"]) >= 2 assert len(reversed_data["items"]) >= 2 # Order should be different (first item in normal should be last in reversed) if len(normal_data["items"]) > 1 and len(reversed_data["items"]) > 1: assert normal_data["items"][0]["id"] != reversed_data["items"][0]["id"] @pytest.mark.asyncio async def test_get_messages_with_empty_filter( client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer] ): """Test getting messages with empty filter object""" test_workspace, test_peer = sample_data # Create a test session and message test_session = models.Session( workspace_name=test_workspace.name, name=str(generate_nanoid()) ) db_session.add(test_session) await db_session.commit() test_message = models.Message( session_name=test_session.name, content="Test message", workspace_name=test_workspace.name, peer_name=test_peer.name, seq_in_session=1, ) db_session.add(test_message) await db_session.commit() response = client.post( f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/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_messages_with_null_filter( client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer] ): """Test getting messages with null filter""" test_workspace, test_peer = sample_data # Create a test session and message test_session = models.Session( workspace_name=test_workspace.name, name=str(generate_nanoid()) ) db_session.add(test_session) await db_session.commit() test_message = models.Message( session_name=test_session.name, content="Test message", workspace_name=test_workspace.name, peer_name=test_peer.name, seq_in_session=1, ) db_session.add(test_message) await db_session.commit() response = client.post( f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/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_messages_no_body( client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer] ): """Test getting messages without any request body""" test_workspace, test_peer = sample_data # Create a test session and message test_session = models.Session( workspace_name=test_workspace.name, name=str(generate_nanoid()) ) db_session.add(test_session) await db_session.commit() test_message = models.Message( session_name=test_session.name, content="Test message", workspace_name=test_workspace.name, peer_name=test_peer.name, seq_in_session=1, ) db_session.add(test_message) await db_session.commit() response = client.post( f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/list" ) assert response.status_code == 200 data = response.json() assert "items" in data assert isinstance(data["items"], list) @pytest.mark.asyncio async def test_get_filtered_messages( client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer] ): test_workspace, test_peer = sample_data # Create a test session and messages test_session = models.Session( workspace_name=test_workspace.name, name=str(generate_nanoid()) ) db_session.add(test_session) await db_session.commit() test_message = models.Message( session_name=test_session.name, content="Test message", workspace_name=test_workspace.name, peer_name=test_peer.name, h_metadata={"key": "value"}, seq_in_session=1, ) test_message2 = models.Message( session_name=test_session.name, content="Test message 2", workspace_name=test_workspace.name, peer_name=test_peer.name, h_metadata={"key": "value2"}, seq_in_session=2, ) db_session.add(test_message) db_session.add(test_message2) await db_session.commit() response = client.post( f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/list", json={"filters": {"metadata": {"key": "value2"}}}, ) assert response.status_code == 200 data = response.json() assert "items" in data assert len(data["items"]) == 1 assert data["items"][0]["content"] == "Test message 2" assert data["items"][0]["peer_id"] == test_peer.name assert data["items"][0]["session_id"] == test_session.name assert data["items"][0]["metadata"] == {"key": "value2"} @pytest.mark.asyncio async def test_get_filtered_messages_with_complex_filter( client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer] ): """Test getting messages with complex metadata filter""" test_workspace, test_peer = sample_data # Create a test session and messages test_session = models.Session( workspace_name=test_workspace.name, name=str(generate_nanoid()) ) db_session.add(test_session) await db_session.commit() test_message1 = models.Message( session_name=test_session.name, content="Message 1", workspace_name=test_workspace.name, peer_name=test_peer.name, h_metadata={"type": "question", "priority": "high", "category": "technical"}, seq_in_session=1, ) test_message2 = models.Message( session_name=test_session.name, content="Message 2", workspace_name=test_workspace.name, peer_name=test_peer.name, h_metadata={"type": "answer", "priority": "high", "category": "technical"}, seq_in_session=2, ) test_message3 = models.Message( session_name=test_session.name, content="Message 3", workspace_name=test_workspace.name, peer_name=test_peer.name, h_metadata={"type": "question", "priority": "low", "category": "general"}, seq_in_session=3, ) db_session.add(test_message1) db_session.add(test_message2) db_session.add(test_message3) await db_session.commit() # Test old-style filter (backward compatibility) response = client.post( f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/list", json={"filters": {"metadata": {"priority": "high", "category": "technical"}}}, ) assert response.status_code == 200 data = response.json() assert "items" in data # Should return messages 1 and 2 (both have high priority and technical category) assert len(data["items"]) >= 2 # Test new-style filter with AND operator response = client.post( f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/list", json={ "filters": { "AND": [ {"metadata": {"priority": "high"}}, {"metadata": {"category": "technical"}}, ] } }, ) assert response.status_code == 200 data = response.json() assert "items" in data assert len(data["items"]) == 2 # Test OR filter to get high priority OR question type response = client.post( f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/list", json={ "filters": { "OR": [ {"metadata": {"priority": "high"}}, {"metadata": {"type": "question"}}, ] } }, ) assert response.status_code == 200 data = response.json() assert "items" in data assert len(data["items"]) == 3 # All messages should match @pytest.mark.asyncio async def test_update_message( client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer] ): test_workspace, test_peer = sample_data # Create a test session and message test_session = models.Session( workspace_name=test_workspace.name, name=str(generate_nanoid()) ) db_session.add(test_session) await db_session.commit() test_message = models.Message( session_name=test_session.name, content="Test message", workspace_name=test_workspace.name, peer_name=test_peer.name, seq_in_session=1, ) db_session.add(test_message) await db_session.commit() response = client.put( f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/{test_message.public_id}", json={"metadata": {"new_key": "new_value"}}, ) assert response.status_code == 200 data = response.json() assert data["metadata"] == {"new_key": "new_value"} @pytest.mark.asyncio async def test_update_message_with_complex_metadata( client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer] ): """Test updating message with complex metadata structure""" test_workspace, test_peer = sample_data # Create a test session and message test_session = models.Session( workspace_name=test_workspace.name, name=str(generate_nanoid()) ) db_session.add(test_session) await db_session.commit() test_message = models.Message( session_name=test_session.name, content="Test message", workspace_name=test_workspace.name, peer_name=test_peer.name, seq_in_session=1, ) db_session.add(test_message) await db_session.commit() complex_metadata = { "tags": ["important", "follow-up"], "score": 8.5, "nested": {"category": "technical", "subcategory": "api"}, "processed": True, } response = client.put( f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/{test_message.public_id}", json={"metadata": complex_metadata}, ) assert response.status_code == 200 data = response.json() assert data["metadata"] == complex_metadata @pytest.mark.asyncio async def test_update_message_empty_metadata( client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer] ): # note that this should not change the metadata of the message. # this test is to ensure that the metadata is not changed when it is set to None. test_workspace, test_peer = sample_data # Create a test session and message test_session = models.Session( workspace_name=test_workspace.name, name=str(generate_nanoid()) ) db_session.add(test_session) await db_session.commit() test_message = models.Message( session_name=test_session.name, content="Test message", workspace_name=test_workspace.name, peer_name=test_peer.name, h_metadata={"test_key": "test_value"}, seq_in_session=1, ) db_session.add(test_message) await db_session.commit() response = client.put( f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/{test_message.public_id}", json={"metadata": None}, ) assert response.status_code == 200 # now ensure that the metadata is not changed response = client.get( f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/{test_message.public_id}" ) assert response.status_code == 200 data = response.json() assert data["metadata"] == {"test_key": "test_value"} @pytest.mark.asyncio async def test_update_message_with_empty_dict_metadata( client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer] ): """Test updating message with empty dictionary metadata""" test_workspace, test_peer = sample_data # Create a test session and message test_session = models.Session( workspace_name=test_workspace.name, name=str(generate_nanoid()) ) db_session.add(test_session) await db_session.commit() test_message = models.Message( session_name=test_session.name, content="Test message", workspace_name=test_workspace.name, peer_name=test_peer.name, h_metadata={"old_key": "old_value"}, seq_in_session=1, ) db_session.add(test_message) await db_session.commit() response = client.put( f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/{test_message.public_id}", json={"metadata": {}}, ) assert response.status_code == 200 data = response.json() assert data["metadata"] == {} @pytest.mark.asyncio async def test_get_single_message( client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer] ): test_workspace, test_peer = sample_data # Create a test session and message test_session = models.Session( workspace_name=test_workspace.name, name=str(generate_nanoid()) ) db_session.add(test_session) await db_session.commit() test_message = models.Message( session_name=test_session.name, content="Test message", workspace_name=test_workspace.name, peer_name=test_peer.name, seq_in_session=1, ) db_session.add(test_message) await db_session.commit() response = client.get( f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/{test_message.public_id}" ) assert response.status_code == 200 data = response.json() assert data["content"] == "Test message" assert data["peer_id"] == test_peer.name assert data["session_id"] == test_session.name assert data["workspace_id"] == test_workspace.name assert data["id"] == test_message.public_id @pytest.mark.asyncio async def test_get_nonexistent_message( client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer] ): """Test getting a message that doesn't exist""" test_workspace, _ = sample_data # Create a test session test_session = models.Session( workspace_name=test_workspace.name, name=str(generate_nanoid()) ) db_session.add(test_session) await db_session.commit() nonexistent_message_id = str(generate_nanoid()) response = client.get( f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/{nonexistent_message_id}" ) assert response.status_code == 404 @pytest.mark.asyncio async def test_update_nonexistent_message( client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer] ): """Test updating a message that doesn't exist""" test_workspace, _ = sample_data # Create a test session test_session = models.Session( workspace_name=test_workspace.name, name=str(generate_nanoid()) ) db_session.add(test_session) await db_session.commit() nonexistent_message_id = str(generate_nanoid()) response = client.put( f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/{nonexistent_message_id}", json={"metadata": {"key": "value"}}, ) assert response.status_code == 404 @pytest.mark.asyncio async def test_create_messages_for_nonexistent_session( client: TestClient, sample_data: tuple[Workspace, Peer] ): """Test creating messages for a session that doesn't exist - should create the session""" test_workspace, test_peer = sample_data nonexistent_session_id = str(generate_nanoid()) response = client.post( f"/v3/workspaces/{test_workspace.name}/sessions/{nonexistent_session_id}/messages", json={ "messages": [ { "content": "Test message", "peer_id": test_peer.name, } ] }, ) # Should create the session and return 201 with the created message assert response.status_code == 201 data = response.json() assert len(data) == 1 assert data[0]["content"] == "Test message" assert data[0]["workspace_id"] == test_workspace.name assert data[0]["session_id"] == nonexistent_session_id assert data[0]["peer_id"] == test_peer.name @pytest.mark.asyncio async def test_get_messages_for_nonexistent_session( client: TestClient, sample_data: tuple[Workspace, Peer] ): """Test getting messages for a session that doesn't exist - should return empty list""" test_workspace, _ = sample_data nonexistent_session_id = str(generate_nanoid()) response = client.post( f"/v3/workspaces/{test_workspace.name}/sessions/{nonexistent_session_id}/messages/list", json={}, ) # Should return 200 with empty results (session doesn't exist = no messages) assert response.status_code == 200 data = response.json() assert "items" in data assert len(data["items"]) == 0 @pytest.mark.asyncio async def test_create_empty_batch_messages( client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer] ): """Test creating an empty batch of messages""" test_workspace, _ = sample_data # Create a test session test_session = models.Session( workspace_name=test_workspace.name, name=str(generate_nanoid()) ) db_session.add(test_session) await db_session.commit() response = client.post( f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages", json={"messages": []}, ) # Should return 422 for validation error (empty list not allowed) assert response.status_code == 422 @pytest.mark.asyncio async def test_create_batch_messages_max_limit( client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer] ): """Test creating batch messages at the maximum limit (100 messages)""" test_workspace, test_peer = sample_data # Create a test session test_session = models.Session( workspace_name=test_workspace.name, name=str(generate_nanoid()) ) db_session.add(test_session) await db_session.commit() # Create exactly 100 messages (the maximum allowed) messages = [ {"content": f"Message {i}", "peer_id": test_peer.name, "metadata": {"index": i}} for i in range(100) ] response = client.post( f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages", json={"messages": messages}, ) assert response.status_code == 201 data = response.json() assert len(data) == 100 assert data[0]["content"] == "Message 0" assert data[99]["content"] == "Message 99" @pytest.mark.asyncio async def test_get_messages_handles_crud_value_error( client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer] ): """Test that ValueError from CRUD is properly handled in get_messages""" test_workspace, _test_peer = sample_data # Create a test session test_session = models.Session( workspace_name=test_workspace.name, name=str(generate_nanoid()) ) db_session.add(test_session) await db_session.commit() # Mock the CRUD function to raise ValueError with patch("src.routers.messages.crud.get_messages") as mock_get: mock_get.side_effect = ValueError("Test CRUD error") response = client.post( f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/list", json={}, ) # Should raise ResourceNotFoundException which gets converted to 404 assert response.status_code == 404 @pytest.mark.asyncio async def test_get_message_handles_not_found( client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer] ): """Test that ResourceNotFoundException is properly handled in get_message""" test_workspace, _ = sample_data # Create a test session test_session = models.Session( workspace_name=test_workspace.name, name=str(generate_nanoid()) ) db_session.add(test_session) await db_session.commit() # Try to get a non-existent message with patch("src.routers.messages.crud.get_message") as mock_get: mock_get.return_value = None response = client.get( f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/nonexistent" ) # Should raise ResourceNotFoundException which gets converted to 404 assert response.status_code == 404 @pytest.mark.asyncio async def test_update_message_handles_crud_value_error( client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer] ): """Test that ValueError from CRUD is properly handled in update_message""" test_workspace, test_peer = sample_data # Create a test session and message test_session = models.Session( workspace_name=test_workspace.name, name=str(generate_nanoid()) ) db_session.add(test_session) await db_session.commit() test_message = models.Message( session_name=test_session.name, content="Test message", workspace_name=test_workspace.name, peer_name=test_peer.name, seq_in_session=1, ) db_session.add(test_message) await db_session.commit() # Mock the CRUD function to raise ValueError with patch("src.routers.messages.crud.update_message") as mock_update: mock_update.side_effect = ValueError("Test CRUD error") response = client.put( f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/{test_message.public_id}", json={"metadata": {"key": "value"}}, ) # Should raise ResourceNotFoundException which gets converted to 404 assert response.status_code == 404 @pytest.mark.asyncio async def test_create_messages_with_file_too_large( client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer] ): """Test that FileTooLargeError is properly handled in create_messages_with_file""" test_workspace, test_peer = sample_data # Create a test session test_session = models.Session( workspace_name=test_workspace.name, name=str(generate_nanoid()) ) db_session.add(test_session) await db_session.commit() # Create a large file that exceeds the size limit import io large_content = b"x" * (10 * 1024 * 1024) # 10MB file file_data = io.BytesIO(large_content) # Mock the settings to make the test deterministic with patch( "src.routers.messages.settings.MAX_FILE_SIZE", 5 * 1024 * 1024 ): # 5MB limit files = {"file": ("large_file.txt", file_data, "text/plain")} form_data = {"peer_id": test_peer.name} response = client.post( f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/upload", files=files, data=form_data, ) # Should raise FileTooLargeError which gets converted to 413 assert response.status_code == 413 @pytest.mark.asyncio async def test_create_message_with_timestamp( client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer] ): """Test creating a message with custom timestamp""" test_workspace, test_peer = sample_data # Create a test session test_session = models.Session( workspace_name=test_workspace.name, name=str(generate_nanoid()) ) db_session.add(test_session) await db_session.commit() # Use a specific timestamp for testing custom_timestamp = datetime.datetime( 2023, 1, 1, 12, 0, 0, tzinfo=datetime.timezone.utc ) response = client.post( f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages", json={ "messages": [ { "content": "Test message with timestamp", "peer_id": test_peer.name, "created_at": custom_timestamp.isoformat(), "metadata": {"test": "with_timestamp"}, } ] }, ) assert response.status_code == 201 data = response.json() assert len(data) == 1 message = data[0] assert message["content"] == "Test message with timestamp" assert message["peer_id"] == test_peer.name assert message["metadata"] == {"test": "with_timestamp"} # Verify the created_at field matches our custom timestamp # Pydantic serializes UTC timezone as 'Z' format (ISO 8601 standard) expected_timestamp = "2023-01-01T12:00:00Z" assert message["created_at"] == expected_timestamp @pytest.mark.asyncio async def test_create_message_without_timestamp_uses_default( client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer] ): """Test creating a message without timestamp uses default timestamp""" test_workspace, test_peer = sample_data # Create a test session test_session = models.Session( workspace_name=test_workspace.name, name=str(generate_nanoid()) ) db_session.add(test_session) await db_session.commit() # Pad the window to absorb client/Postgres clock skew under Docker. before_request = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta( seconds=1 ) response = client.post( f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages", json={ "messages": [ { "content": "Test message without timestamp", "peer_id": test_peer.name, "metadata": {"test": "no_timestamp"}, } ] }, ) after_request = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta( seconds=1 ) assert response.status_code == 201 data = response.json() assert len(data) == 1 message = data[0] assert message["content"] == "Test message without timestamp" assert message["peer_id"] == test_peer.name assert message["metadata"] == {"test": "no_timestamp"} # Verify the created_at field is between our before/after times message_created_at = datetime.datetime.fromisoformat( message["created_at"].replace("Z", "+00:00") ) assert before_request <= message_created_at <= after_request @pytest.mark.asyncio async def test_create_batch_messages_with_mixed_timestamps( client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer] ): """Test creating batch messages with some having custom timestamps and others using default""" test_workspace, test_peer = sample_data # Create a test session test_session = models.Session( workspace_name=test_workspace.name, name=str(generate_nanoid()) ) db_session.add(test_session) await db_session.commit() # Use specific timestamps for testing timestamp1 = datetime.datetime(2023, 1, 1, 12, 0, 0, tzinfo=datetime.timezone.utc) timestamp2 = datetime.datetime(2023, 1, 2, 12, 0, 0, tzinfo=datetime.timezone.utc) before_request = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta( seconds=1 ) response = client.post( f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages", json={ "messages": [ { "content": "Message 1 with timestamp", "peer_id": test_peer.name, "created_at": timestamp1.isoformat(), "metadata": {"type": "custom_timestamp"}, }, { "content": "Message 2 without timestamp", "peer_id": test_peer.name, "metadata": {"type": "default_timestamp"}, }, { "content": "Message 3 with timestamp", "peer_id": test_peer.name, "created_at": timestamp2.isoformat(), "metadata": {"type": "custom_timestamp"}, }, ] }, ) after_request = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta( seconds=1 ) assert response.status_code == 201 data = response.json() assert len(data) == 3 # Check first message with custom timestamp assert data[0]["content"] == "Message 1 with timestamp" assert ( data[0]["created_at"] == "2023-01-01T12:00:00Z" ) # Pydantic converts to Z format assert data[0]["metadata"] == {"type": "custom_timestamp"} # Check second message with default timestamp assert data[1]["content"] == "Message 2 without timestamp" message2_created_at = datetime.datetime.fromisoformat( data[1]["created_at"].replace("Z", "+00:00") ) assert before_request <= message2_created_at <= after_request assert data[1]["metadata"] == {"type": "default_timestamp"} # Check third message with custom timestamp assert data[2]["content"] == "Message 3 with timestamp" assert ( data[2]["created_at"] == "2023-01-02T12:00:00Z" ) # Pydantic converts to Z format assert data[2]["metadata"] == {"type": "custom_timestamp"} @pytest.mark.asyncio async def test_create_message_with_null_timestamp( client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer] ): """Test creating a message with null timestamp uses default""" test_workspace, test_peer = sample_data # Create a test session test_session = models.Session( workspace_name=test_workspace.name, name=str(generate_nanoid()) ) db_session.add(test_session) await db_session.commit() before_request = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta( seconds=1 ) response = client.post( f"/v3/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages", json={ "messages": [ { "content": "Test message with null timestamp", "peer_id": test_peer.name, "created_at": None, "metadata": {"test": "null_timestamp"}, } ] }, ) after_request = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta( seconds=1 ) assert response.status_code == 201 data = response.json() assert len(data) == 1 message = data[0] assert message["content"] == "Test message with null timestamp" assert message["metadata"] == {"test": "null_timestamp"} # Verify the created_at field uses default (current time) message_created_at = datetime.datetime.fromisoformat( message["created_at"].replace("Z", "+00:00") ) assert before_request <= message_created_at <= after_request