honcho/tests/routes/test_messages.py

1375 lines
46 KiB
Python

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