diff --git a/tests/__pycache__/conftest.cpython-312-pytest-8.2.2.pyc b/tests/__pycache__/conftest.cpython-312-pytest-8.2.2.pyc new file mode 100644 index 0000000..6ebcb75 Binary files /dev/null and b/tests/__pycache__/conftest.cpython-312-pytest-8.2.2.pyc differ diff --git a/tests/__pycache__/test_auth.cpython-312-pytest-8.2.2.pyc b/tests/__pycache__/test_auth.cpython-312-pytest-8.2.2.pyc new file mode 100644 index 0000000..07d140e Binary files /dev/null and b/tests/__pycache__/test_auth.cpython-312-pytest-8.2.2.pyc differ diff --git a/tests/__pycache__/test_chat.cpython-312-pytest-8.2.2.pyc b/tests/__pycache__/test_chat.cpython-312-pytest-8.2.2.pyc new file mode 100644 index 0000000..7764638 Binary files /dev/null and b/tests/__pycache__/test_chat.cpython-312-pytest-8.2.2.pyc differ diff --git a/tests/__pycache__/test_files.cpython-312-pytest-8.2.2.pyc b/tests/__pycache__/test_files.cpython-312-pytest-8.2.2.pyc new file mode 100644 index 0000000..eaace56 Binary files /dev/null and b/tests/__pycache__/test_files.cpython-312-pytest-8.2.2.pyc differ diff --git a/tests/__pycache__/test_labels.cpython-312-pytest-8.2.2.pyc b/tests/__pycache__/test_labels.cpython-312-pytest-8.2.2.pyc new file mode 100644 index 0000000..9b7cb73 Binary files /dev/null and b/tests/__pycache__/test_labels.cpython-312-pytest-8.2.2.pyc differ diff --git a/tests/__pycache__/test_members.cpython-312-pytest-8.2.2.pyc b/tests/__pycache__/test_members.cpython-312-pytest-8.2.2.pyc new file mode 100644 index 0000000..7376205 Binary files /dev/null and b/tests/__pycache__/test_members.cpython-312-pytest-8.2.2.pyc differ diff --git a/tests/__pycache__/test_projects.cpython-312-pytest-8.2.2.pyc b/tests/__pycache__/test_projects.cpython-312-pytest-8.2.2.pyc new file mode 100644 index 0000000..0248ec1 Binary files /dev/null and b/tests/__pycache__/test_projects.cpython-312-pytest-8.2.2.pyc differ diff --git a/tests/__pycache__/test_streaming.cpython-312-pytest-8.2.2.pyc b/tests/__pycache__/test_streaming.cpython-312-pytest-8.2.2.pyc new file mode 100644 index 0000000..5721278 Binary files /dev/null and b/tests/__pycache__/test_streaming.cpython-312-pytest-8.2.2.pyc differ diff --git a/tests/__pycache__/test_tasks.cpython-312-pytest-8.2.2.pyc b/tests/__pycache__/test_tasks.cpython-312-pytest-8.2.2.pyc new file mode 100644 index 0000000..155c3fa Binary files /dev/null and b/tests/__pycache__/test_tasks.cpython-312-pytest-8.2.2.pyc differ diff --git a/tests/__pycache__/test_websocket.cpython-312-pytest-8.2.2.pyc b/tests/__pycache__/test_websocket.cpython-312-pytest-8.2.2.pyc new file mode 100644 index 0000000..1f405a7 Binary files /dev/null and b/tests/__pycache__/test_websocket.cpython-312-pytest-8.2.2.pyc differ diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..77e21d6 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,174 @@ +""" +Conftest для E2E тестов Team Board API +Содержит фикстуры для подключения к API и создания тестовых данных +""" +import pytest +import pytest_asyncio +import httpx +import uuid +from typing import Dict, Any, Optional + + +@pytest.fixture(scope="session") +def base_url(): + """Базовый URL для API""" + return "http://localhost:8100/api/v1" + + +@pytest_asyncio.fixture +async def admin_token(base_url: str) -> str: + """Получает JWT токен администратора""" + async with httpx.AsyncClient() as client: + response = await client.post(f"{base_url}/auth/login", json={ + "login": "admin", + "password": "teamboard" + }) + assert response.status_code == 200 + data = response.json() + return data["token"] + + +@pytest.fixture +def agent_token(): + """Bearer токен для агента из документации""" + return "tb-coder-dev-token" + + +@pytest.fixture +def http_client(base_url: str, admin_token: str): + """HTTP клиент с авторизацией для админа""" + headers = {"Authorization": f"Bearer {admin_token}"} + return httpx.AsyncClient(base_url=base_url, headers=headers, timeout=30.0) + + +@pytest.fixture +def agent_client(base_url: str, agent_token: str): + """HTTP клиент с авторизацией для агента""" + headers = {"Authorization": f"Bearer {agent_token}"} + return httpx.AsyncClient(base_url=base_url, headers=headers, timeout=30.0) + + +@pytest_asyncio.fixture +async def test_project(http_client: httpx.AsyncClient) -> Dict[str, Any]: + """Создаёт тестовый проект и удаляет его после теста""" + project_slug = f"test-project-{uuid.uuid4().hex[:8]}" + project_data = { + "name": f"Test Project {project_slug}", + "slug": project_slug, + "description": "Test project for E2E tests", + "repo_urls": ["https://github.com/test/repo"] + } + + response = await http_client.post("/projects", json=project_data) + assert response.status_code == 201 + project = response.json() + + yield project + + # Cleanup - удаляем проект + await http_client.delete(f"/projects/{project['id']}") + + +@pytest_asyncio.fixture +async def test_user(http_client: httpx.AsyncClient) -> Dict[str, Any]: + """Создаёт тестового пользователя и удаляет его после теста""" + user_slug = f"test-user-{uuid.uuid4().hex[:8]}" + user_data = { + "name": f"Test User {user_slug}", + "slug": user_slug, + "type": "human", + "role": "member" + } + + response = await http_client.post("/members", json=user_data) + assert response.status_code == 201 + user = response.json() + + yield user + + # Cleanup - помечаем пользователя неактивным + await http_client.delete(f"/members/{user['id']}") + + +@pytest_asyncio.fixture +async def test_agent(http_client: httpx.AsyncClient) -> Dict[str, Any]: + """Создаёт тестового агента и удаляет его после теста""" + agent_slug = f"test-agent-{uuid.uuid4().hex[:8]}" + agent_data = { + "name": f"Test Agent {agent_slug}", + "slug": agent_slug, + "type": "agent", + "role": "member", + "agent_config": { + "capabilities": ["coding", "testing"], + "labels": ["backend", "python"], + "chat_listen": "mentions", + "task_listen": "assigned", + "prompt": "Test agent for E2E tests" + } + } + + response = await http_client.post("/members", json=agent_data) + assert response.status_code == 201 + agent = response.json() + + yield agent + + # Cleanup + await http_client.delete(f"/members/{agent['id']}") + + +@pytest_asyncio.fixture +async def test_task(http_client: httpx.AsyncClient, test_project: Dict[str, Any]) -> Dict[str, Any]: + """Создаёт тестовую задачу в проекте""" + task_data = { + "title": "Test Task", + "description": "Test task for E2E tests", + "type": "task", + "status": "backlog", + "priority": "medium" + } + + response = await http_client.post(f"/tasks?project_id={test_project['id']}", json=task_data) + assert response.status_code == 201 + task = response.json() + + yield task + + # Cleanup - задача удалится вместе с проектом + + +@pytest_asyncio.fixture +async def test_label(http_client: httpx.AsyncClient) -> Dict[str, Any]: + """Создаёт тестовый лейбл""" + label_slug = f"test-label-{uuid.uuid4().hex[:8]}" + label_data = { + "name": label_slug, + "color": "#ff5733" + } + + response = await http_client.post("/labels", json=label_data) + assert response.status_code == 201 + label = response.json() + + yield label + + # Cleanup + await http_client.delete(f"/labels/{label['id']}") + + +def assert_uuid(value: str): + """Проверяет что строка является валидным UUID""" + try: + uuid.UUID(value) + except (ValueError, TypeError): + pytest.fail(f"'{value}' is not a valid UUID") + + +def assert_timestamp(value: str): + """Проверяет что строка является валидным ISO timestamp""" + import datetime + try: + datetime.datetime.fromisoformat(value.replace('Z', '+00:00')) + except ValueError: + pytest.fail(f"'{value}' is not a valid ISO timestamp") \ No newline at end of file diff --git a/tests/pytest.ini b/tests/pytest.ini new file mode 100644 index 0000000..926a3c2 --- /dev/null +++ b/tests/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +asyncio_mode = auto +asyncio_default_fixture_loop_scope = session \ No newline at end of file diff --git a/tests/requirements.txt b/tests/requirements.txt new file mode 100644 index 0000000..98f2abb --- /dev/null +++ b/tests/requirements.txt @@ -0,0 +1,5 @@ +pytest==8.2.2 +pytest-asyncio==0.24.0 +httpx==0.27.0 +websockets==13.1 +uuid==1.30 \ No newline at end of file diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..1a3b3f3 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,117 @@ +""" +Тесты аутентификации - логин и проверка JWT токена +""" +import pytest +import httpx +from conftest import assert_uuid + + +@pytest.mark.asyncio +async def test_login_success(base_url: str): + """Test successful admin login and JWT token validation""" + async with httpx.AsyncClient() as client: + response = await client.post(f"{base_url}/auth/login", json={ + "login": "admin", + "password": "teamboard" + }) + + assert response.status_code == 200 + data = response.json() + + # Проверяем структуру ответа + assert "token" in data + assert "member_id" in data + assert "slug" in data + assert "role" in data + + # Проверяем валидность данных + assert_uuid(data["member_id"]) + assert data["slug"] == "admin" + assert data["role"] == "owner" + assert isinstance(data["token"], str) + assert len(data["token"]) > 10 # JWT должен быть длинным + + +@pytest.mark.asyncio +async def test_login_with_slug(base_url: str): + """Test login using slug instead of name""" + async with httpx.AsyncClient() as client: + response = await client.post(f"{base_url}/auth/login", json={ + "login": "admin", # используем slug + "password": "teamboard" + }) + + assert response.status_code == 200 + data = response.json() + assert data["slug"] == "admin" + + +@pytest.mark.asyncio +async def test_login_invalid_credentials(base_url: str): + """Test login with invalid credentials returns 401""" + async with httpx.AsyncClient() as client: + response = await client.post(f"{base_url}/auth/login", json={ + "login": "admin", + "password": "wrong_password" + }) + + assert response.status_code == 401 + + +@pytest.mark.asyncio +async def test_login_missing_fields(base_url: str): + """Test login with missing required fields""" + async with httpx.AsyncClient() as client: + # Без пароля + response = await client.post(f"{base_url}/auth/login", json={ + "login": "admin" + }) + assert response.status_code == 422 + + # Без логина + response = await client.post(f"{base_url}/auth/login", json={ + "password": "teamboard" + }) + assert response.status_code == 422 + + +@pytest.mark.asyncio +async def test_protected_endpoint_without_auth(base_url: str): + """Test that protected endpoints require authentication""" + async with httpx.AsyncClient() as client: + response = await client.get(f"{base_url}/members") + assert response.status_code == 401 + + +@pytest.mark.asyncio +async def test_protected_endpoint_with_invalid_token(base_url: str): + """Test protected endpoint with invalid JWT token""" + headers = {"Authorization": "Bearer invalid_token"} + async with httpx.AsyncClient() as client: + response = await client.get(f"{base_url}/members", headers=headers) + assert response.status_code == 401 + + +@pytest.mark.asyncio +async def test_protected_endpoint_with_valid_token(http_client: httpx.AsyncClient): + """Test that valid JWT token allows access to protected endpoints""" + response = await http_client.get("/members") + assert response.status_code == 200 + assert isinstance(response.json(), list) + + +@pytest.mark.asyncio +async def test_agent_token_authentication(agent_client: httpx.AsyncClient): + """Test that agent token works for API access""" + response = await agent_client.get("/members") + assert response.status_code == 200 + assert isinstance(response.json(), list) + + +@pytest.mark.asyncio +async def test_token_query_parameter(base_url: str, admin_token: str): + """Test authentication using token query parameter""" + async with httpx.AsyncClient() as client: + response = await client.get(f"{base_url}/members?token={admin_token}") + assert response.status_code == 200 + assert isinstance(response.json(), list) \ No newline at end of file diff --git a/tests/test_chat.py b/tests/test_chat.py new file mode 100644 index 0000000..219db8c --- /dev/null +++ b/tests/test_chat.py @@ -0,0 +1,315 @@ +""" +Тесты работы с чатами и сообщениями - отправка, mentions, история +""" +import pytest +import httpx +import uuid +from conftest import assert_uuid, assert_timestamp + + +@pytest.mark.asyncio +async def test_get_project_messages(http_client: httpx.AsyncClient, test_project: dict): + """Test getting messages from project chat""" + chat_id = test_project["chat_id"] + + response = await http_client.get(f"/messages?chat_id={chat_id}") + assert response.status_code == 200 + + messages = response.json() + assert isinstance(messages, list) + + +@pytest.mark.asyncio +async def test_get_task_comments(http_client: httpx.AsyncClient, test_task: dict): + """Test getting comments for specific task""" + response = await http_client.get(f"/messages?task_id={test_task['id']}") + assert response.status_code == 200 + + messages = response.json() + assert isinstance(messages, list) + + +@pytest.mark.asyncio +async def test_send_message_to_project_chat(http_client: httpx.AsyncClient, test_project: dict): + """Test sending message to project chat""" + chat_id = test_project["chat_id"] + message_data = { + "chat_id": chat_id, + "content": "Hello from test! This is a test message." + } + + response = await http_client.post("/messages", json=message_data) + assert response.status_code == 201 + + message = response.json() + assert message["content"] == message_data["content"] + assert message["chat_id"] == chat_id + assert message["task_id"] is None + assert message["parent_id"] is None + assert message["author_type"] in ["human", "agent"] + assert message["author"] is not None + assert_uuid(message["id"]) + assert_uuid(message["author_id"]) + assert_timestamp(message["created_at"]) + + # Проверяем структуру автора + assert "id" in message["author"] + assert "slug" in message["author"] + assert "name" in message["author"] + + +@pytest.mark.asyncio +async def test_send_comment_to_task(http_client: httpx.AsyncClient, test_task: dict): + """Test sending comment to task""" + message_data = { + "task_id": test_task["id"], + "content": "This is a comment on the task." + } + + response = await http_client.post("/messages", json=message_data) + assert response.status_code == 201 + + message = response.json() + assert message["content"] == message_data["content"] + assert message["chat_id"] is None + assert message["task_id"] == test_task["id"] + assert message["parent_id"] is None + + +@pytest.mark.asyncio +async def test_send_message_with_mentions(http_client: httpx.AsyncClient, test_project: dict, test_user: dict): + """Test sending message with user mentions""" + chat_id = test_project["chat_id"] + message_data = { + "chat_id": chat_id, + "content": f"Hey @{test_user['slug']}, check this out!", + "mentions": [test_user["id"]] + } + + response = await http_client.post("/messages", json=message_data) + assert response.status_code == 201 + + message = response.json() + assert len(message["mentions"]) == 1 + assert message["mentions"][0]["id"] == test_user["id"] + assert message["mentions"][0]["slug"] == test_user["slug"] + assert message["mentions"][0]["name"] == test_user["name"] + + +@pytest.mark.asyncio +async def test_send_agent_message_with_thinking(agent_client: httpx.AsyncClient, test_project: dict): + """Test agent sending message with thinking content""" + chat_id = test_project["chat_id"] + message_data = { + "chat_id": chat_id, + "content": "I think this is the best approach.", + "thinking": "Let me analyze this problem step by step. First, I need to consider..." + } + + response = await agent_client.post("/messages", json=message_data) + assert response.status_code == 201 + + message = response.json() + assert message["content"] == message_data["content"] + assert message["thinking"] == message_data["thinking"] + assert message["author_type"] == "agent" + + +@pytest.mark.asyncio +async def test_send_reply_in_thread(http_client: httpx.AsyncClient, test_project: dict): + """Test sending reply to existing message (thread)""" + chat_id = test_project["chat_id"] + + # Отправляем основное сообщение + original_data = { + "chat_id": chat_id, + "content": "Original message" + } + + response = await http_client.post("/messages", json=original_data) + assert response.status_code == 201 + original_message = response.json() + + # Отправляем ответ в тред + reply_data = { + "chat_id": chat_id, + "parent_id": original_message["id"], + "content": "This is a reply in thread" + } + + response = await http_client.post("/messages", json=reply_data) + assert response.status_code == 201 + + reply = response.json() + assert reply["parent_id"] == original_message["id"] + assert reply["content"] == reply_data["content"] + assert reply["chat_id"] == chat_id + + +@pytest.mark.asyncio +async def test_get_thread_replies(http_client: httpx.AsyncClient, test_project: dict): + """Test getting replies in thread""" + chat_id = test_project["chat_id"] + + # Отправляем основное сообщение + original_data = { + "chat_id": chat_id, + "content": "Message with replies" + } + + response = await http_client.post("/messages", json=original_data) + assert response.status_code == 201 + original_message = response.json() + + # Отправляем несколько ответов + for i in range(3): + reply_data = { + "chat_id": chat_id, + "parent_id": original_message["id"], + "content": f"Reply {i+1}" + } + await http_client.post("/messages", json=reply_data) + + # Получаем ответы в треде + response = await http_client.get(f"/messages/{original_message['id']}/replies") + assert response.status_code == 200 + + replies = response.json() + assert len(replies) == 3 + for reply in replies: + assert reply["parent_id"] == original_message["id"] + + +@pytest.mark.asyncio +async def test_send_message_without_chat_or_task_fails(http_client: httpx.AsyncClient): + """Test that message without chat_id or task_id returns 400""" + message_data = { + "content": "Message without destination" + } + + response = await http_client.post("/messages", json=message_data) + assert response.status_code == 400 + + +@pytest.mark.asyncio +async def test_send_message_to_nonexistent_chat_fails(http_client: httpx.AsyncClient): + """Test sending message to non-existent chat""" + fake_chat_id = str(uuid.uuid4()) + message_data = { + "chat_id": fake_chat_id, + "content": "Message to nowhere" + } + + response = await http_client.post("/messages", json=message_data) + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_send_message_to_nonexistent_task_fails(http_client: httpx.AsyncClient): + """Test sending message to non-existent task""" + fake_task_id = str(uuid.uuid4()) + message_data = { + "task_id": fake_task_id, + "content": "Comment on nowhere" + } + + response = await http_client.post("/messages", json=message_data) + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_get_messages_with_pagination(http_client: httpx.AsyncClient, test_project: dict): + """Test getting messages with limit and offset""" + chat_id = test_project["chat_id"] + + # Отправляем несколько сообщений + for i in range(5): + message_data = { + "chat_id": chat_id, + "content": f"Test message {i+1}" + } + await http_client.post("/messages", json=message_data) + + # Получаем с лимитом + response = await http_client.get(f"/messages?chat_id={chat_id}&limit=2") + assert response.status_code == 200 + + messages = response.json() + assert len(messages) <= 2 + + # Получаем с отступом + response = await http_client.get(f"/messages?chat_id={chat_id}&limit=2&offset=2") + assert response.status_code == 200 + + offset_messages = response.json() + assert len(offset_messages) <= 2 + + +@pytest.mark.asyncio +async def test_get_messages_with_parent_filter(http_client: httpx.AsyncClient, test_project: dict): + """Test getting messages filtered by parent_id""" + chat_id = test_project["chat_id"] + + # Отправляем основное сообщение + original_data = { + "chat_id": chat_id, + "content": "Parent message for filter test" + } + + response = await http_client.post("/messages", json=original_data) + assert response.status_code == 201 + parent_message = response.json() + + # Отправляем ответ + reply_data = { + "chat_id": chat_id, + "parent_id": parent_message["id"], + "content": "Child reply" + } + + await http_client.post("/messages", json=reply_data) + + # Фильтруем по parent_id + response = await http_client.get(f"/messages?parent_id={parent_message['id']}") + assert response.status_code == 200 + + messages = response.json() + for message in messages: + assert message["parent_id"] == parent_message["id"] + + +@pytest.mark.asyncio +async def test_message_order_chronological(http_client: httpx.AsyncClient, test_project: dict): + """Test that messages are returned in chronological order""" + chat_id = test_project["chat_id"] + + # Отправляем сообщения с задержкой + import asyncio + + messages_sent = [] + for i in range(3): + message_data = { + "chat_id": chat_id, + "content": f"Ordered message {i+1}" + } + response = await http_client.post("/messages", json=message_data) + assert response.status_code == 201 + messages_sent.append(response.json()) + await asyncio.sleep(0.1) # Небольшая задержка + + # Получаем сообщения + response = await http_client.get(f"/messages?chat_id={chat_id}&limit=10") + assert response.status_code == 200 + + messages = response.json() + + # Фильтруем только наши тестовые сообщения + test_messages = [m for m in messages if "Ordered message" in m["content"]] + + # Проверяем хронологический порядок + if len(test_messages) >= 2: + for i in range(len(test_messages) - 1): + current_time = test_messages[i]["created_at"] + next_time = test_messages[i + 1]["created_at"] + # Более поздние сообщения должны идти раньше (DESC order) + assert next_time >= current_time \ No newline at end of file diff --git a/tests/test_files.py b/tests/test_files.py new file mode 100644 index 0000000..b576c90 --- /dev/null +++ b/tests/test_files.py @@ -0,0 +1,404 @@ +""" +Тесты работы с файлами - upload, download, файлы проектов +""" +import pytest +import httpx +import uuid +import tempfile +import os +from conftest import assert_uuid, assert_timestamp + + +@pytest.mark.asyncio +async def test_upload_file(http_client: httpx.AsyncClient): + """Test uploading file via multipart/form-data""" + # Создаём временный файл + with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f: + f.write("This is a test file for upload\nLine 2\nLine 3") + temp_file_path = f.name + + try: + # Загружаем файл + with open(temp_file_path, 'rb') as f: + files = {'file': ('test_upload.txt', f, 'text/plain')} + response = await http_client.post("/upload", files=files) + + assert response.status_code == 200 + + upload_data = response.json() + assert "file_id" in upload_data + assert "filename" in upload_data + assert "mime_type" in upload_data + assert "size" in upload_data + assert "storage_name" in upload_data + + assert_uuid(upload_data["file_id"]) + assert upload_data["filename"] == "test_upload.txt" + assert upload_data["mime_type"] == "text/plain" + assert upload_data["size"] > 0 + assert isinstance(upload_data["storage_name"], str) + + finally: + # Удаляем временный файл + os.unlink(temp_file_path) + + +@pytest.mark.asyncio +async def test_upload_large_file_fails(http_client: httpx.AsyncClient): + """Test that uploading too large file returns 413""" + # Создаём файл размером больше 50MB (лимит из API) + # Но для теста создадим файл 1MB и проверим что endpoint работает + large_content = "A" * (1024 * 1024) # 1MB + + with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f: + f.write(large_content) + temp_file_path = f.name + + try: + with open(temp_file_path, 'rb') as f: + files = {'file': ('large_file.txt', f, 'text/plain')} + response = await http_client.post("/upload", files=files) + + # В тестовой среде файл 1MB должен проходить успешно + # В продакшене лимит 50MB вернёт 413 + assert response.status_code in [200, 413] + + finally: + os.unlink(temp_file_path) + + +@pytest.mark.asyncio +async def test_upload_without_file_fails(http_client: httpx.AsyncClient): + """Test uploading without file field returns 422""" + response = await http_client.post("/upload", files={}) + assert response.status_code == 422 + + +@pytest.mark.asyncio +async def test_download_attachment_by_id(): + """Test downloading uploaded file (requires attachment ID)""" + # Этот тест сложно реализовать без создания сообщения с вложением + # Оставляем как плейсхолдер для полноценной реализации + pass + + +# Тесты файлов проектов + +@pytest.mark.asyncio +async def test_get_project_files_list(http_client: httpx.AsyncClient, test_project: dict): + """Test getting list of project files""" + response = await http_client.get(f"/projects/{test_project['id']}/files") + assert response.status_code == 200 + + files = response.json() + assert isinstance(files, list) + + +@pytest.mark.asyncio +async def test_upload_file_to_project(http_client: httpx.AsyncClient, test_project: dict): + """Test uploading file directly to project""" + # Создаём временный файл + with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f: + f.write("# Project Documentation\n\nThis is a test document for the project.") + temp_file_path = f.name + + try: + # Загружаем файл в проект + with open(temp_file_path, 'rb') as f: + files = {'file': ('README.md', f, 'text/markdown')} + data = {'description': 'Project documentation file'} + response = await http_client.post( + f"/projects/{test_project['id']}/files", + files=files, + data=data + ) + + assert response.status_code == 201 + + project_file = response.json() + assert project_file["filename"] == "README.md" + assert project_file["description"] == "Project documentation file" + assert project_file["mime_type"] == "text/markdown" + assert project_file["size"] > 0 + assert_uuid(project_file["id"]) + + # Проверяем информацию о загрузившем + assert "uploaded_by" in project_file + assert "id" in project_file["uploaded_by"] + assert "slug" in project_file["uploaded_by"] + assert "name" in project_file["uploaded_by"] + + assert_timestamp(project_file["created_at"]) + assert_timestamp(project_file["updated_at"]) + + finally: + os.unlink(temp_file_path) + + +@pytest.mark.asyncio +async def test_upload_file_to_project_without_description(http_client: httpx.AsyncClient, test_project: dict): + """Test uploading file to project without description""" + with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f: + f.write("Simple text file without description") + temp_file_path = f.name + + try: + with open(temp_file_path, 'rb') as f: + files = {'file': ('simple.txt', f, 'text/plain')} + response = await http_client.post( + f"/projects/{test_project['id']}/files", + files=files + ) + + assert response.status_code == 201 + + project_file = response.json() + assert project_file["filename"] == "simple.txt" + assert project_file["description"] is None + + finally: + os.unlink(temp_file_path) + + +@pytest.mark.asyncio +async def test_upload_file_to_nonexistent_project(http_client: httpx.AsyncClient): + """Test uploading file to non-existent project returns 404""" + fake_project_id = str(uuid.uuid4()) + + with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f: + f.write("File for nowhere") + temp_file_path = f.name + + try: + with open(temp_file_path, 'rb') as f: + files = {'file': ('test.txt', f, 'text/plain')} + response = await http_client.post( + f"/projects/{fake_project_id}/files", + files=files + ) + + assert response.status_code == 404 + + finally: + os.unlink(temp_file_path) + + +@pytest.mark.asyncio +async def test_get_project_file_info(http_client: httpx.AsyncClient, test_project: dict): + """Test getting specific project file information""" + # Сначала загружаем файл + with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: + f.write('{"test": "data", "project": "file"}') + temp_file_path = f.name + + try: + with open(temp_file_path, 'rb') as f: + files = {'file': ('data.json', f, 'application/json')} + data = {'description': 'Test JSON data'} + upload_response = await http_client.post( + f"/projects/{test_project['id']}/files", + files=files, + data=data + ) + + assert upload_response.status_code == 201 + project_file = upload_response.json() + + # Получаем информацию о файле + response = await http_client.get(f"/projects/{test_project['id']}/files/{project_file['id']}") + assert response.status_code == 200 + + file_info = response.json() + assert file_info["id"] == project_file["id"] + assert file_info["filename"] == "data.json" + assert file_info["description"] == "Test JSON data" + assert file_info["mime_type"] == "application/json" + + finally: + os.unlink(temp_file_path) + + +@pytest.mark.asyncio +async def test_download_project_file(http_client: httpx.AsyncClient, test_project: dict): + """Test downloading project file""" + # Сначала загружаем файл + test_content = "Downloadable content\nLine 2" + with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f: + f.write(test_content) + temp_file_path = f.name + + try: + with open(temp_file_path, 'rb') as f: + files = {'file': ('download_test.txt', f, 'text/plain')} + upload_response = await http_client.post( + f"/projects/{test_project['id']}/files", + files=files + ) + + assert upload_response.status_code == 201 + project_file = upload_response.json() + + # Скачиваем файл + response = await http_client.get( + f"/projects/{test_project['id']}/files/{project_file['id']}/download" + ) + assert response.status_code == 200 + + # Проверяем заголовки + assert "content-type" in response.headers + assert "content-length" in response.headers + + # Проверяем содержимое + downloaded_content = response.text + assert downloaded_content == test_content + + finally: + os.unlink(temp_file_path) + + +@pytest.mark.asyncio +async def test_update_project_file_description(http_client: httpx.AsyncClient, test_project: dict): + """Test updating project file description""" + # Загружаем файл + with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f: + f.write("File to update description") + temp_file_path = f.name + + try: + with open(temp_file_path, 'rb') as f: + files = {'file': ('update_desc.txt', f, 'text/plain')} + data = {'description': 'Original description'} + upload_response = await http_client.post( + f"/projects/{test_project['id']}/files", + files=files, + data=data + ) + + assert upload_response.status_code == 201 + project_file = upload_response.json() + + # Обновляем описание + update_data = {"description": "Updated file description"} + response = await http_client.patch( + f"/projects/{test_project['id']}/files/{project_file['id']}", + json=update_data + ) + assert response.status_code == 200 + + updated_file = response.json() + assert updated_file["description"] == "Updated file description" + assert updated_file["id"] == project_file["id"] + assert updated_file["filename"] == project_file["filename"] + + finally: + os.unlink(temp_file_path) + + +@pytest.mark.asyncio +async def test_clear_project_file_description(http_client: httpx.AsyncClient, test_project: dict): + """Test clearing project file description""" + # Загружаем файл с описанием + with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f: + f.write("File to clear description") + temp_file_path = f.name + + try: + with open(temp_file_path, 'rb') as f: + files = {'file': ('clear_desc.txt', f, 'text/plain')} + data = {'description': 'Description to be cleared'} + upload_response = await http_client.post( + f"/projects/{test_project['id']}/files", + files=files, + data=data + ) + + assert upload_response.status_code == 201 + project_file = upload_response.json() + + # Очищаем описание + update_data = {"description": None} + response = await http_client.patch( + f"/projects/{test_project['id']}/files/{project_file['id']}", + json=update_data + ) + assert response.status_code == 200 + + updated_file = response.json() + assert updated_file["description"] is None + + finally: + os.unlink(temp_file_path) + + +@pytest.mark.asyncio +async def test_delete_project_file(http_client: httpx.AsyncClient, test_project: dict): + """Test deleting project file""" + # Загружаем файл + with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f: + f.write("File to be deleted") + temp_file_path = f.name + + try: + with open(temp_file_path, 'rb') as f: + files = {'file': ('to_delete.txt', f, 'text/plain')} + upload_response = await http_client.post( + f"/projects/{test_project['id']}/files", + files=files + ) + + assert upload_response.status_code == 201 + project_file = upload_response.json() + + # Удаляем файл + response = await http_client.delete( + f"/projects/{test_project['id']}/files/{project_file['id']}" + ) + assert response.status_code == 200 + + data = response.json() + assert data["ok"] is True + + # Проверяем что файл действительно удалён + response = await http_client.get( + f"/projects/{test_project['id']}/files/{project_file['id']}" + ) + assert response.status_code == 404 + + finally: + os.unlink(temp_file_path) + + +@pytest.mark.asyncio +async def test_search_project_files(http_client: httpx.AsyncClient, test_project: dict): + """Test searching project files by filename""" + # Загружаем несколько файлов + test_files = ["searchable_doc.md", "another_file.txt", "searchable_config.json"] + uploaded_files = [] + + for filename in test_files: + with tempfile.NamedTemporaryFile(mode='w', delete=False) as f: + f.write(f"Content of {filename}") + temp_file_path = f.name + + try: + with open(temp_file_path, 'rb') as f: + files = {'file': (filename, f, 'text/plain')} + response = await http_client.post( + f"/projects/{test_project['id']}/files", + files=files + ) + assert response.status_code == 201 + uploaded_files.append(response.json()) + + finally: + os.unlink(temp_file_path) + + # Ищем файлы со словом "searchable" + response = await http_client.get( + f"/projects/{test_project['id']}/files?search=searchable" + ) + assert response.status_code == 200 + + files = response.json() + searchable_files = [f for f in files if "searchable" in f["filename"].lower()] + assert len(searchable_files) >= 2 # Должно найти 2 файла с "searchable" \ No newline at end of file diff --git a/tests/test_labels.py b/tests/test_labels.py new file mode 100644 index 0000000..709fd5d --- /dev/null +++ b/tests/test_labels.py @@ -0,0 +1,352 @@ +""" +Тесты работы с лейблами - CRUD операции, привязка к задачам +""" +import pytest +import httpx +import uuid +from conftest import assert_uuid + + +@pytest.mark.asyncio +async def test_get_labels_list(http_client: httpx.AsyncClient): + """Test getting list of all labels""" + response = await http_client.get("/labels") + assert response.status_code == 200 + + labels = response.json() + assert isinstance(labels, list) + + +@pytest.mark.asyncio +async def test_create_label(http_client: httpx.AsyncClient): + """Test creating new label""" + label_name = f"test-label-{uuid.uuid4().hex[:8]}" + label_data = { + "name": label_name, + "color": "#ff5733" + } + + response = await http_client.post("/labels", json=label_data) + assert response.status_code == 201 + + label = response.json() + assert label["name"] == label_name + assert label["color"] == "#ff5733" + assert_uuid(label["id"]) + + # Cleanup + await http_client.delete(f"/labels/{label['id']}") + + +@pytest.mark.asyncio +async def test_create_label_default_color(http_client: httpx.AsyncClient): + """Test creating label without color uses default""" + label_name = f"default-color-{uuid.uuid4().hex[:8]}" + label_data = {"name": label_name} + + response = await http_client.post("/labels", json=label_data) + assert response.status_code == 201 + + label = response.json() + assert label["name"] == label_name + assert label["color"] == "#6366f1" # Цвет по умолчанию + + # Cleanup + await http_client.delete(f"/labels/{label['id']}") + + +@pytest.mark.asyncio +async def test_create_label_duplicate_name_fails(http_client: httpx.AsyncClient, test_label: dict): + """Test creating label with duplicate name returns 409""" + label_data = { + "name": test_label["name"], # Используем существующее имя + "color": "#123456" + } + + response = await http_client.post("/labels", json=label_data) + assert response.status_code == 409 + + +@pytest.mark.asyncio +async def test_update_label(http_client: httpx.AsyncClient, test_label: dict): + """Test updating label name and color""" + update_data = { + "name": f"updated-{test_label['name']}", + "color": "#00ff00" + } + + response = await http_client.patch(f"/labels/{test_label['id']}", json=update_data) + assert response.status_code == 200 + + label = response.json() + assert label["name"] == update_data["name"] + assert label["color"] == update_data["color"] + assert label["id"] == test_label["id"] + + +@pytest.mark.asyncio +async def test_update_label_name_only(http_client: httpx.AsyncClient, test_label: dict): + """Test updating only label name""" + original_color = test_label["color"] + update_data = {"name": f"name-only-{uuid.uuid4().hex[:8]}"} + + response = await http_client.patch(f"/labels/{test_label['id']}", json=update_data) + assert response.status_code == 200 + + label = response.json() + assert label["name"] == update_data["name"] + assert label["color"] == original_color # Цвет не изменился + + +@pytest.mark.asyncio +async def test_update_label_color_only(http_client: httpx.AsyncClient, test_label: dict): + """Test updating only label color""" + original_name = test_label["name"] + update_data = {"color": "#purple"} + + response = await http_client.patch(f"/labels/{test_label['id']}", json=update_data) + assert response.status_code == 200 + + label = response.json() + assert label["name"] == original_name # Имя не изменилось + assert label["color"] == "#purple" + + +@pytest.mark.asyncio +async def test_update_nonexistent_label(http_client: httpx.AsyncClient): + """Test updating non-existent label returns 404""" + fake_label_id = str(uuid.uuid4()) + update_data = {"name": "nonexistent"} + + response = await http_client.patch(f"/labels/{fake_label_id}", json=update_data) + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_delete_label(http_client: httpx.AsyncClient): + """Test deleting label""" + # Создаём временный лейбл для удаления + label_name = f"to-delete-{uuid.uuid4().hex[:8]}" + label_data = {"name": label_name, "color": "#ff0000"} + + response = await http_client.post("/labels", json=label_data) + assert response.status_code == 201 + label = response.json() + + # Удаляем лейбл + response = await http_client.delete(f"/labels/{label['id']}") + assert response.status_code == 200 + + data = response.json() + assert data["ok"] is True + + +@pytest.mark.asyncio +async def test_delete_nonexistent_label(http_client: httpx.AsyncClient): + """Test deleting non-existent label returns 404""" + fake_label_id = str(uuid.uuid4()) + + response = await http_client.delete(f"/labels/{fake_label_id}") + assert response.status_code == 404 + + +# Тесты привязки лейблов к задачам + +@pytest.mark.asyncio +async def test_add_label_to_task(http_client: httpx.AsyncClient, test_task: dict, test_label: dict): + """Test adding label to task""" + response = await http_client.post(f"/tasks/{test_task['id']}/labels/{test_label['id']}") + assert response.status_code == 200 + + data = response.json() + assert data["ok"] is True + + # Проверяем что лейбл добавился к задаче + task_response = await http_client.get(f"/tasks/{test_task['id']}") + assert task_response.status_code == 200 + + task = task_response.json() + assert test_label["name"] in task["labels"] + + +@pytest.mark.asyncio +async def test_add_multiple_labels_to_task(http_client: httpx.AsyncClient, test_task: dict): + """Test adding multiple labels to task""" + # Создаём несколько лейблов + labels_created = [] + for i in range(3): + label_data = { + "name": f"multi-label-{i}-{uuid.uuid4().hex[:6]}", + "color": f"#{i:02d}{i:02d}{i:02d}" + } + response = await http_client.post("/labels", json=label_data) + assert response.status_code == 201 + labels_created.append(response.json()) + + # Добавляем все лейблы к задаче + for label in labels_created: + response = await http_client.post(f"/tasks/{test_task['id']}/labels/{label['id']}") + assert response.status_code == 200 + + # Проверяем что все лейблы добавились + task_response = await http_client.get(f"/tasks/{test_task['id']}") + assert task_response.status_code == 200 + + task = task_response.json() + for label in labels_created: + assert label["name"] in task["labels"] + + # Cleanup + for label in labels_created: + await http_client.delete(f"/labels/{label['id']}") + + +@pytest.mark.asyncio +async def test_add_nonexistent_label_to_task(http_client: httpx.AsyncClient, test_task: dict): + """Test adding non-existent label to task returns 404""" + fake_label_id = str(uuid.uuid4()) + + response = await http_client.post(f"/tasks/{test_task['id']}/labels/{fake_label_id}") + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_add_label_to_nonexistent_task(http_client: httpx.AsyncClient, test_label: dict): + """Test adding label to non-existent task returns 404""" + fake_task_id = str(uuid.uuid4()) + + response = await http_client.post(f"/tasks/{fake_task_id}/labels/{test_label['id']}") + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_add_duplicate_label_to_task(http_client: httpx.AsyncClient, test_task: dict, test_label: dict): + """Test adding same label twice to task (should be idempotent)""" + # Добавляем лейбл первый раз + response = await http_client.post(f"/tasks/{test_task['id']}/labels/{test_label['id']}") + assert response.status_code == 200 + + # Добавляем тот же лейбл повторно + response = await http_client.post(f"/tasks/{test_task['id']}/labels/{test_label['id']}") + # В зависимости от реализации может быть 200 (идемпотентность) или 409 (конфликт) + assert response.status_code in [200, 409] + + +@pytest.mark.asyncio +async def test_remove_label_from_task(http_client: httpx.AsyncClient, test_task: dict, test_label: dict): + """Test removing label from task""" + # Сначала добавляем лейбл + response = await http_client.post(f"/tasks/{test_task['id']}/labels/{test_label['id']}") + assert response.status_code == 200 + + # Затем удаляем + response = await http_client.delete(f"/tasks/{test_task['id']}/labels/{test_label['id']}") + assert response.status_code == 200 + + data = response.json() + assert data["ok"] is True + + # Проверяем что лейбл удалился + task_response = await http_client.get(f"/tasks/{test_task['id']}") + assert task_response.status_code == 200 + + task = task_response.json() + assert test_label["name"] not in task["labels"] + + +@pytest.mark.asyncio +async def test_remove_nonexistent_label_from_task(http_client: httpx.AsyncClient, test_task: dict): + """Test removing non-existent label from task returns 404""" + fake_label_id = str(uuid.uuid4()) + + response = await http_client.delete(f"/tasks/{test_task['id']}/labels/{fake_label_id}") + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_remove_label_from_nonexistent_task(http_client: httpx.AsyncClient, test_label: dict): + """Test removing label from non-existent task returns 404""" + fake_task_id = str(uuid.uuid4()) + + response = await http_client.delete(f"/tasks/{fake_task_id}/labels/{test_label['id']}") + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_remove_label_not_attached_to_task(http_client: httpx.AsyncClient, test_task: dict, test_label: dict): + """Test removing label that's not attached to task""" + # Не добавляем лейбл к задаче, сразу пытаемся удалить + response = await http_client.delete(f"/tasks/{test_task['id']}/labels/{test_label['id']}") + # В зависимости от реализации может быть 404 (не найден) или 200 (идемпотентность) + assert response.status_code in [200, 404] + + +@pytest.mark.asyncio +async def test_filter_tasks_by_label(http_client: httpx.AsyncClient, test_project: dict): + """Test filtering tasks by label""" + # Создаём лейбл для фильтрации + label_data = { + "name": f"filter-test-{uuid.uuid4().hex[:8]}", + "color": "#123456" + } + response = await http_client.post("/labels", json=label_data) + assert response.status_code == 201 + filter_label = response.json() + + # Создаём задачу с лейблом + task_data = { + "title": "Task with specific label", + "labels": [filter_label["name"]] + } + response = await http_client.post(f"/tasks?project_id={test_project['id']}", json=task_data) + assert response.status_code == 201 + labeled_task = response.json() + + # Фильтруем задачи по лейблу + response = await http_client.get(f"/tasks?label={filter_label['name']}") + assert response.status_code == 200 + + tasks = response.json() + # Все найденные задачи должны содержать указанный лейбл + for task in tasks: + assert filter_label["name"] in task["labels"] + + # Наша задача должна быть в результатах + task_ids = [t["id"] for t in tasks] + assert labeled_task["id"] in task_ids + + # Cleanup + await http_client.delete(f"/labels/{filter_label['id']}") + + +@pytest.mark.asyncio +async def test_create_task_with_labels(http_client: httpx.AsyncClient, test_project: dict): + """Test creating task with labels from the start""" + # Создаём несколько лейблов + labels_data = [ + {"name": f"initial-label-1-{uuid.uuid4().hex[:6]}", "color": "#ff0000"}, + {"name": f"initial-label-2-{uuid.uuid4().hex[:6]}", "color": "#00ff00"} + ] + + created_labels = [] + for label_data in labels_data: + response = await http_client.post("/labels", json=label_data) + assert response.status_code == 201 + created_labels.append(response.json()) + + # Создаём задачу с лейблами + task_data = { + "title": "Task with initial labels", + "labels": [label["name"] for label in created_labels] + } + + response = await http_client.post(f"/tasks?project_id={test_project['id']}", json=task_data) + assert response.status_code == 201 + + task = response.json() + for label in created_labels: + assert label["name"] in task["labels"] + + # Cleanup + for label in created_labels: + await http_client.delete(f"/labels/{label['id']}") \ No newline at end of file diff --git a/tests/test_members.py b/tests/test_members.py new file mode 100644 index 0000000..3ffd707 --- /dev/null +++ b/tests/test_members.py @@ -0,0 +1,287 @@ +""" +Тесты работы с участниками - CRUD операции, смена slug, токены агентов +""" +import pytest +import httpx +import uuid +from conftest import assert_uuid, assert_timestamp + + +@pytest.mark.asyncio +async def test_get_members_list(http_client: httpx.AsyncClient): + """Test getting list of all members""" + response = await http_client.get("/members") + assert response.status_code == 200 + + members = response.json() + assert isinstance(members, list) + assert len(members) > 0 + + # Проверяем структуру первого участника + member = members[0] + assert "id" in member + assert "name" in member + assert "slug" in member + assert "type" in member + assert "role" in member + assert "status" in member + assert "is_active" in member + assert "last_seen_at" in member + + assert_uuid(member["id"]) + assert member["type"] in ["human", "agent", "bridge"] + assert member["role"] in ["owner", "admin", "member"] + assert member["status"] in ["online", "offline", "busy"] + assert isinstance(member["is_active"], bool) + + +@pytest.mark.asyncio +async def test_get_member_by_id(http_client: httpx.AsyncClient, test_user: dict): + """Test getting specific member by ID""" + response = await http_client.get(f"/members/{test_user['id']}") + assert response.status_code == 200 + + member = response.json() + assert member["id"] == test_user["id"] + assert member["name"] == test_user["name"] + assert member["slug"] == test_user["slug"] + assert member["type"] == "human" + + +@pytest.mark.asyncio +async def test_get_member_not_found(http_client: httpx.AsyncClient): + """Test getting non-existent member returns 404""" + fake_id = str(uuid.uuid4()) + response = await http_client.get(f"/members/{fake_id}") + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_create_human_member(http_client: httpx.AsyncClient): + """Test creating new human member""" + member_slug = f"test-human-{uuid.uuid4().hex[:8]}" + member_data = { + "name": f"Test Human {member_slug}", + "slug": member_slug, + "type": "human", + "role": "member" + } + + response = await http_client.post("/members", json=member_data) + assert response.status_code == 201 + + member = response.json() + assert member["name"] == member_data["name"] + assert member["slug"] == member_data["slug"] + assert member["type"] == "human" + assert member["role"] == "member" + assert_uuid(member["id"]) + + # У человека не должно быть токена + assert "token" not in member + + # Cleanup + await http_client.delete(f"/members/{member['id']}") + + +@pytest.mark.asyncio +async def test_create_agent_member(http_client: httpx.AsyncClient): + """Test creating new agent member with configuration""" + agent_slug = f"test-agent-{uuid.uuid4().hex[:8]}" + agent_data = { + "name": f"Test Agent {agent_slug}", + "slug": agent_slug, + "type": "agent", + "role": "member", + "agent_config": { + "capabilities": ["coding", "testing", "documentation"], + "labels": ["backend", "python", "api"], + "chat_listen": "mentions", + "task_listen": "assigned", + "prompt": "You are a test automation agent", + "model": "gpt-4" + } + } + + response = await http_client.post("/members", json=agent_data) + assert response.status_code == 201 + + agent = response.json() + assert agent["name"] == agent_data["name"] + assert agent["slug"] == agent_data["slug"] + assert agent["type"] == "agent" + assert agent["role"] == "member" + assert_uuid(agent["id"]) + + # У агента должен быть токен при создании + assert "token" in agent + assert agent["token"].startswith("tb-") + + # Проверяем конфигурацию агента + config = agent["agent_config"] + assert config["capabilities"] == agent_data["agent_config"]["capabilities"] + assert config["labels"] == agent_data["agent_config"]["labels"] + assert config["chat_listen"] == "mentions" + assert config["task_listen"] == "assigned" + + # Cleanup + await http_client.delete(f"/members/{agent['id']}") + + +@pytest.mark.asyncio +async def test_create_member_duplicate_slug(http_client: httpx.AsyncClient, test_user: dict): + """Test creating member with duplicate slug returns 409""" + member_data = { + "name": "Another User", + "slug": test_user["slug"], # Используем существующий slug + "type": "human", + "role": "member" + } + + response = await http_client.post("/members", json=member_data) + assert response.status_code == 409 + + +@pytest.mark.asyncio +async def test_update_member_info(http_client: httpx.AsyncClient, test_user: dict): + """Test updating member information""" + update_data = { + "name": "Updated Test User", + "role": "admin" + } + + response = await http_client.patch(f"/members/{test_user['id']}", json=update_data) + assert response.status_code == 200 + + member = response.json() + assert member["name"] == "Updated Test User" + assert member["role"] == "admin" + assert member["id"] == test_user["id"] + assert member["slug"] == test_user["slug"] # slug не изменился + + +@pytest.mark.asyncio +async def test_update_member_slug(http_client: httpx.AsyncClient, test_user: dict): + """Test updating member slug""" + new_slug = f"updated-slug-{uuid.uuid4().hex[:8]}" + update_data = {"slug": new_slug} + + response = await http_client.patch(f"/members/{test_user['id']}", json=update_data) + assert response.status_code == 200 + + member = response.json() + assert member["slug"] == new_slug + + +@pytest.mark.asyncio +async def test_update_agent_config(http_client: httpx.AsyncClient, test_agent: dict): + """Test updating agent configuration""" + new_config = { + "agent_config": { + "capabilities": ["testing", "debugging"], + "labels": ["frontend", "javascript"], + "chat_listen": "all", + "task_listen": "mentions", + "prompt": "Updated test agent", + "model": "claude-3" + } + } + + response = await http_client.patch(f"/members/{test_agent['id']}", json=new_config) + assert response.status_code == 200 + + agent = response.json() + config = agent["agent_config"] + assert config["capabilities"] == new_config["agent_config"]["capabilities"] + assert config["labels"] == new_config["agent_config"]["labels"] + assert config["chat_listen"] == "all" + assert config["task_listen"] == "mentions" + + +@pytest.mark.asyncio +async def test_regenerate_agent_token(http_client: httpx.AsyncClient, test_agent: dict): + """Test regenerating agent token""" + response = await http_client.post(f"/members/{test_agent['id']}/regenerate-token") + assert response.status_code == 200 + + data = response.json() + assert "token" in data + assert data["token"].startswith("tb-") + assert data["token"] != test_agent["token"] # Новый токен должен отличаться + + +@pytest.mark.asyncio +async def test_regenerate_token_for_human_fails(http_client: httpx.AsyncClient, test_user: dict): + """Test that regenerating token for human returns 400""" + response = await http_client.post(f"/members/{test_user['id']}/regenerate-token") + assert response.status_code == 400 + + +@pytest.mark.asyncio +async def test_revoke_agent_token(http_client: httpx.AsyncClient, test_agent: dict): + """Test revoking agent token""" + response = await http_client.post(f"/members/{test_agent['id']}/revoke-token") + assert response.status_code == 200 + + data = response.json() + assert data["ok"] is True + + +@pytest.mark.asyncio +async def test_revoke_token_for_human_fails(http_client: httpx.AsyncClient, test_user: dict): + """Test that revoking token for human returns 400""" + response = await http_client.post(f"/members/{test_user['id']}/revoke-token") + assert response.status_code == 400 + + +@pytest.mark.asyncio +async def test_update_member_status(agent_client: httpx.AsyncClient): + """Test updating member status (for agents)""" + response = await agent_client.patch("/members/me/status?status=busy") + assert response.status_code == 200 + + data = response.json() + assert data["status"] == "busy" + + +@pytest.mark.asyncio +async def test_delete_member(http_client: httpx.AsyncClient): + """Test soft deleting member (sets is_active=false)""" + # Создаём временного пользователя для удаления + member_slug = f"to-delete-{uuid.uuid4().hex[:8]}" + member_data = { + "name": f"User to Delete {member_slug}", + "slug": member_slug, + "type": "human", + "role": "member" + } + + response = await http_client.post("/members", json=member_data) + assert response.status_code == 201 + member = response.json() + + # Удаляем пользователя + response = await http_client.delete(f"/members/{member['id']}") + assert response.status_code == 200 + + data = response.json() + assert data["ok"] is True + + # Проверяем что пользователь помечен неактивным + response = await http_client.get(f"/members/{member['id']}") + assert response.status_code == 200 + deleted_member = response.json() + assert deleted_member["is_active"] is False + + +@pytest.mark.asyncio +async def test_get_members_include_inactive(http_client: httpx.AsyncClient): + """Test getting members list with inactive ones""" + response = await http_client.get("/members?include_inactive=true") + assert response.status_code == 200 + + members = response.json() + # Должен содержать как активных, так и неактивных + has_inactive = any(not m["is_active"] for m in members) + # Может и не быть неактивных, но запрос должен пройти + assert isinstance(members, list) \ No newline at end of file diff --git a/tests/test_projects.py b/tests/test_projects.py new file mode 100644 index 0000000..ddce97f --- /dev/null +++ b/tests/test_projects.py @@ -0,0 +1,298 @@ +""" +Тесты работы с проектами - CRUD операции, участники проектов +""" +import pytest +import httpx +import uuid +from conftest import assert_uuid, assert_timestamp + + +@pytest.mark.asyncio +async def test_get_projects_list(http_client: httpx.AsyncClient): + """Test getting list of all projects""" + response = await http_client.get("/projects") + assert response.status_code == 200 + + projects = response.json() + assert isinstance(projects, list) + + if projects: # Если есть проекты + project = projects[0] + assert "id" in project + assert "name" in project + assert "slug" in project + assert "description" in project + assert "repo_urls" in project + assert "status" in project + assert "task_counter" in project + assert "chat_id" in project + assert "auto_assign" in project + + assert_uuid(project["id"]) + assert project["status"] in ["active", "archived", "paused"] + assert isinstance(project["task_counter"], int) + assert isinstance(project["auto_assign"], bool) + + +@pytest.mark.asyncio +async def test_get_project_by_id(http_client: httpx.AsyncClient, test_project: dict): + """Test getting specific project by ID""" + response = await http_client.get(f"/projects/{test_project['id']}") + assert response.status_code == 200 + + project = response.json() + assert project["id"] == test_project["id"] + assert project["name"] == test_project["name"] + assert project["slug"] == test_project["slug"] + assert project["status"] == "active" + + +@pytest.mark.asyncio +async def test_get_project_not_found(http_client: httpx.AsyncClient): + """Test getting non-existent project returns 404""" + fake_id = str(uuid.uuid4()) + response = await http_client.get(f"/projects/{fake_id}") + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_create_project(http_client: httpx.AsyncClient): + """Test creating new project""" + project_slug = f"new-project-{uuid.uuid4().hex[:8]}" + project_data = { + "name": f"New Test Project {project_slug}", + "slug": project_slug, + "description": "A brand new test project", + "repo_urls": [ + "https://github.com/test/repo1", + "https://github.com/test/repo2" + ] + } + + response = await http_client.post("/projects", json=project_data) + assert response.status_code == 201 + + project = response.json() + assert project["name"] == project_data["name"] + assert project["slug"] == project_data["slug"] + assert project["description"] == project_data["description"] + assert project["repo_urls"] == project_data["repo_urls"] + assert project["status"] == "active" + assert project["task_counter"] == 0 + assert project["auto_assign"] is True # По умолчанию + assert_uuid(project["id"]) + assert_uuid(project["chat_id"]) # Автоматически создаётся основной чат + + # Cleanup + await http_client.delete(f"/projects/{project['id']}") + + +@pytest.mark.asyncio +async def test_create_project_duplicate_slug(http_client: httpx.AsyncClient, test_project: dict): + """Test creating project with duplicate slug returns 409""" + project_data = { + "name": "Another Project", + "slug": test_project["slug"], # Используем существующий slug + "description": "Should fail" + } + + response = await http_client.post("/projects", json=project_data) + assert response.status_code == 409 + + +@pytest.mark.asyncio +async def test_create_project_minimal_data(http_client: httpx.AsyncClient): + """Test creating project with minimal required data""" + project_slug = f"minimal-{uuid.uuid4().hex[:8]}" + project_data = { + "name": f"Minimal Project {project_slug}", + "slug": project_slug + # description и repo_urls опциональны + } + + response = await http_client.post("/projects", json=project_data) + assert response.status_code == 201 + + project = response.json() + assert project["name"] == project_data["name"] + assert project["slug"] == project_data["slug"] + assert project["description"] is None + assert project["repo_urls"] == [] + + # Cleanup + await http_client.delete(f"/projects/{project['id']}") + + +@pytest.mark.asyncio +async def test_update_project(http_client: httpx.AsyncClient, test_project: dict): + """Test updating project information""" + update_data = { + "name": "Updated Project Name", + "description": "Updated description", + "repo_urls": ["https://github.com/updated/repo"], + "auto_assign": False + } + + response = await http_client.patch(f"/projects/{test_project['id']}", json=update_data) + assert response.status_code == 200 + + project = response.json() + assert project["name"] == "Updated Project Name" + assert project["description"] == "Updated description" + assert project["repo_urls"] == ["https://github.com/updated/repo"] + assert project["auto_assign"] is False + assert project["id"] == test_project["id"] + assert project["slug"] == test_project["slug"] # slug не изменился + + +@pytest.mark.asyncio +async def test_update_project_slug(http_client: httpx.AsyncClient, test_project: dict): + """Test updating project slug""" + new_slug = f"updated-slug-{uuid.uuid4().hex[:8]}" + update_data = {"slug": new_slug} + + response = await http_client.patch(f"/projects/{test_project['id']}", json=update_data) + assert response.status_code == 200 + + project = response.json() + assert project["slug"] == new_slug + + +@pytest.mark.asyncio +async def test_update_project_status(http_client: httpx.AsyncClient, test_project: dict): + """Test updating project status""" + update_data = {"status": "archived"} + + response = await http_client.patch(f"/projects/{test_project['id']}", json=update_data) + assert response.status_code == 200 + + project = response.json() + assert project["status"] == "archived" + + +@pytest.mark.asyncio +async def test_delete_project(http_client: httpx.AsyncClient): + """Test deleting project""" + # Создаём временный проект для удаления + project_slug = f"to-delete-{uuid.uuid4().hex[:8]}" + project_data = { + "name": f"Project to Delete {project_slug}", + "slug": project_slug, + "description": "Will be deleted" + } + + response = await http_client.post("/projects", json=project_data) + assert response.status_code == 201 + project = response.json() + + # Удаляем проект + response = await http_client.delete(f"/projects/{project['id']}") + assert response.status_code == 200 + + data = response.json() + assert data["ok"] is True + + # Проверяем что проект действительно удалён + response = await http_client.get(f"/projects/{project['id']}") + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_get_project_members(http_client: httpx.AsyncClient, test_project: dict): + """Test getting project members list""" + response = await http_client.get(f"/projects/{test_project['id']}/members") + assert response.status_code == 200 + + members = response.json() + assert isinstance(members, list) + + if members: # Если есть участники + member = members[0] + assert "id" in member + assert "name" in member + assert "slug" in member + assert "type" in member + assert "role" in member + + assert_uuid(member["id"]) + assert member["type"] in ["human", "agent", "bridge"] + assert member["role"] in ["owner", "admin", "member"] + + +@pytest.mark.asyncio +async def test_add_member_to_project(http_client: httpx.AsyncClient, test_project: dict, test_user: dict): + """Test adding member to project""" + response = await http_client.post(f"/projects/{test_project['id']}/members", json={ + "member_id": test_user["id"] + }) + assert response.status_code == 200 + + data = response.json() + assert data["ok"] is True + + # Проверяем что участник добавлен + response = await http_client.get(f"/projects/{test_project['id']}/members") + assert response.status_code == 200 + + members = response.json() + member_ids = [m["id"] for m in members] + assert test_user["id"] in member_ids + + +@pytest.mark.asyncio +async def test_add_nonexistent_member_to_project(http_client: httpx.AsyncClient, test_project: dict): + """Test adding non-existent member to project returns 404""" + fake_member_id = str(uuid.uuid4()) + response = await http_client.post(f"/projects/{test_project['id']}/members", json={ + "member_id": fake_member_id + }) + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_add_duplicate_member_to_project(http_client: httpx.AsyncClient, test_project: dict, test_user: dict): + """Test adding already existing member to project returns 409""" + # Добавляем участника первый раз + response = await http_client.post(f"/projects/{test_project['id']}/members", json={ + "member_id": test_user["id"] + }) + assert response.status_code == 200 + + # Пытаемся добавить его же повторно + response = await http_client.post(f"/projects/{test_project['id']}/members", json={ + "member_id": test_user["id"] + }) + assert response.status_code == 409 + + +@pytest.mark.asyncio +async def test_remove_member_from_project(http_client: httpx.AsyncClient, test_project: dict, test_user: dict): + """Test removing member from project""" + # Сначала добавляем участника + response = await http_client.post(f"/projects/{test_project['id']}/members", json={ + "member_id": test_user["id"] + }) + assert response.status_code == 200 + + # Затем удаляем + response = await http_client.delete(f"/projects/{test_project['id']}/members/{test_user['id']}") + assert response.status_code == 200 + + data = response.json() + assert data["ok"] is True + + # Проверяем что участник удалён + response = await http_client.get(f"/projects/{test_project['id']}/members") + assert response.status_code == 200 + + members = response.json() + member_ids = [m["id"] for m in members] + assert test_user["id"] not in member_ids + + +@pytest.mark.asyncio +async def test_remove_nonexistent_member_from_project(http_client: httpx.AsyncClient, test_project: dict): + """Test removing non-existent member from project returns 404""" + fake_member_id = str(uuid.uuid4()) + response = await http_client.delete(f"/projects/{test_project['id']}/members/{fake_member_id}") + assert response.status_code == 404 \ No newline at end of file diff --git a/tests/test_streaming.py b/tests/test_streaming.py new file mode 100644 index 0000000..003ae3d --- /dev/null +++ b/tests/test_streaming.py @@ -0,0 +1,538 @@ +""" +Тесты агентного стриминга - WebSocket события stream.start/delta/tool/end +""" +import pytest +import asyncio +import websockets +import json +import uuid +from conftest import assert_uuid + + +@pytest.mark.asyncio +async def test_agent_stream_events_structure(agent_token: str, test_project: dict): + """Test structure of agent streaming events""" + uri = f"ws://localhost:8100/ws?token={agent_token}" + + try: + async with websockets.connect(uri, timeout=10) as websocket: + # Ждём аутентификацию + auth_response = await websocket.recv() + auth_data = json.loads(auth_response) + assert auth_data["type"] == "auth.ok" + + agent_id = auth_data["data"]["member_id"] + agent_slug = auth_data["data"]["slug"] + + # Симулируем события стриминга (обычно отправляются сервером, не клиентом) + # Но проверяем структуру событий которые должны прийти + + stream_id = str(uuid.uuid4()) + + # 1. agent.stream.start + start_event = { + "type": "agent.stream.start", + "data": { + "stream_id": stream_id, + "project_id": test_project["id"], + "chat_id": test_project["chat_id"], + "task_id": None, + "agent_id": agent_id, + "agent_slug": agent_slug + } + } + + # 2. agent.stream.delta + delta_event = { + "type": "agent.stream.delta", + "data": { + "stream_id": stream_id, + "delta": "Hello, I'm thinking about this task...", + "agent_id": agent_id, + "agent_slug": agent_slug + } + } + + # 3. agent.stream.tool + tool_event = { + "type": "agent.stream.tool", + "data": { + "stream_id": stream_id, + "tool_name": "web_search", + "tool_args": {"query": "python testing best practices"}, + "tool_result": {"results": ["result1", "result2"]}, + "agent_id": agent_id, + "agent_slug": agent_slug + } + } + + # 4. agent.stream.end + end_event = { + "type": "agent.stream.end", + "data": { + "stream_id": stream_id, + "final_message": "I've completed the analysis and here are my findings...", + "tool_log": [ + { + "name": "web_search", + "args": {"query": "python testing best practices"}, + "result": {"results": ["result1", "result2"]}, + "error": None + } + ], + "agent_id": agent_id, + "agent_slug": agent_slug + } + } + + # Проверяем структуру событий + assert_stream_start_structure(start_event) + assert_stream_delta_structure(delta_event) + assert_stream_tool_structure(tool_event) + assert_stream_end_structure(end_event) + + except Exception as e: + pytest.fail(f"Stream events structure test failed: {e}") + + +def assert_stream_start_structure(event): + """Validate agent.stream.start event structure""" + assert event["type"] == "agent.stream.start" + assert "data" in event + + data = event["data"] + assert "stream_id" in data + assert "project_id" in data + assert "chat_id" in data + assert "task_id" in data # может быть None + assert "agent_id" in data + assert "agent_slug" in data + + assert_uuid(data["stream_id"]) + assert_uuid(data["project_id"]) + assert_uuid(data["agent_id"]) + assert isinstance(data["agent_slug"], str) + + if data["chat_id"]: + assert_uuid(data["chat_id"]) + if data["task_id"]: + assert_uuid(data["task_id"]) + + +def assert_stream_delta_structure(event): + """Validate agent.stream.delta event structure""" + assert event["type"] == "agent.stream.delta" + assert "data" in event + + data = event["data"] + assert "stream_id" in data + assert "delta" in data + assert "agent_id" in data + assert "agent_slug" in data + + assert_uuid(data["stream_id"]) + assert_uuid(data["agent_id"]) + assert isinstance(data["delta"], str) + assert isinstance(data["agent_slug"], str) + + +def assert_stream_tool_structure(event): + """Validate agent.stream.tool event structure""" + assert event["type"] == "agent.stream.tool" + assert "data" in event + + data = event["data"] + assert "stream_id" in data + assert "tool_name" in data + assert "tool_args" in data + assert "tool_result" in data + assert "agent_id" in data + assert "agent_slug" in data + + assert_uuid(data["stream_id"]) + assert_uuid(data["agent_id"]) + assert isinstance(data["tool_name"], str) + assert isinstance(data["agent_slug"], str) + + # tool_args и tool_result могут быть любыми JSON объектами + assert data["tool_args"] is not None + assert data["tool_result"] is not None + + +def assert_stream_end_structure(event): + """Validate agent.stream.end event structure""" + assert event["type"] == "agent.stream.end" + assert "data" in event + + data = event["data"] + assert "stream_id" in data + assert "final_message" in data + assert "tool_log" in data + assert "agent_id" in data + assert "agent_slug" in data + + assert_uuid(data["stream_id"]) + assert_uuid(data["agent_id"]) + assert isinstance(data["final_message"], str) + assert isinstance(data["tool_log"], list) + assert isinstance(data["agent_slug"], str) + + # Проверяем структуру tool_log + for tool_entry in data["tool_log"]: + assert "name" in tool_entry + assert "args" in tool_entry + assert "result" in tool_entry + # "error" может отсутствовать или быть None + + +@pytest.mark.asyncio +async def test_agent_stream_task_context(agent_token: str, test_task: dict): + """Test agent streaming in context of specific task""" + uri = f"ws://localhost:8100/ws?token={agent_token}" + + try: + async with websockets.connect(uri, timeout=10) as websocket: + # Ждём аутентификацию + auth_response = await websocket.recv() + auth_data = json.loads(auth_response) + assert auth_data["type"] == "auth.ok" + + agent_id = auth_data["data"]["member_id"] + agent_slug = auth_data["data"]["slug"] + + stream_id = str(uuid.uuid4()) + + # Стриминг в контексте задачи + start_event = { + "type": "agent.stream.start", + "data": { + "stream_id": stream_id, + "project_id": test_task["project"]["id"], + "chat_id": None, # Комментарий к задаче, не чат + "task_id": test_task["id"], + "agent_id": agent_id, + "agent_slug": agent_slug + } + } + + assert_stream_start_structure(start_event) + + # Проверяем что task_id корректно указан + assert start_event["data"]["task_id"] == test_task["id"] + assert start_event["data"]["chat_id"] is None + + except Exception as e: + pytest.fail(f"Stream task context test failed: {e}") + + +@pytest.mark.asyncio +async def test_agent_stream_with_multiple_tools(agent_token: str, test_project: dict): + """Test agent streaming with multiple tool calls""" + uri = f"ws://localhost:8100/ws?token={agent_token}" + + try: + async with websockets.connect(uri, timeout=10) as websocket: + # Ждём аутентификацию + auth_response = await websocket.recv() + auth_data = json.loads(auth_response) + + agent_id = auth_data["data"]["member_id"] + agent_slug = auth_data["data"]["slug"] + stream_id = str(uuid.uuid4()) + + # Симулируем последовательность вызовов инструментов + tools_sequence = [ + { + "name": "web_search", + "args": {"query": "python best practices"}, + "result": {"results": ["doc1", "doc2"]} + }, + { + "name": "read_file", + "args": {"filename": "requirements.txt"}, + "result": {"content": "pytest==7.0.0\nhttpx==0.24.0"} + }, + { + "name": "execute_command", + "args": {"command": "pytest --version"}, + "result": {"output": "pytest 7.0.0", "exit_code": 0} + } + ] + + # Создаём события для каждого инструмента + for i, tool in enumerate(tools_sequence): + tool_event = { + "type": "agent.stream.tool", + "data": { + "stream_id": stream_id, + "tool_name": tool["name"], + "tool_args": tool["args"], + "tool_result": tool["result"], + "agent_id": agent_id, + "agent_slug": agent_slug + } + } + assert_stream_tool_structure(tool_event) + + # Финальное событие с полным tool_log + end_event = { + "type": "agent.stream.end", + "data": { + "stream_id": stream_id, + "final_message": "I've completed the multi-step analysis.", + "tool_log": [ + { + "name": tool["name"], + "args": tool["args"], + "result": tool["result"], + "error": None + } for tool in tools_sequence + ], + "agent_id": agent_id, + "agent_slug": agent_slug + } + } + + assert_stream_end_structure(end_event) + assert len(end_event["data"]["tool_log"]) == 3 + + except Exception as e: + pytest.fail(f"Multiple tools stream test failed: {e}") + + +@pytest.mark.asyncio +async def test_agent_stream_with_tool_error(agent_token: str, test_project: dict): + """Test agent streaming when tool call fails""" + uri = f"ws://localhost:8100/ws?token={agent_token}" + + try: + async with websockets.connect(uri, timeout=10) as websocket: + # Ждём аутентификацию + auth_response = await websocket.recv() + auth_data = json.loads(auth_response) + + agent_id = auth_data["data"]["member_id"] + agent_slug = auth_data["data"]["slug"] + stream_id = str(uuid.uuid4()) + + # Инструмент с ошибкой + tool_event = { + "type": "agent.stream.tool", + "data": { + "stream_id": stream_id, + "tool_name": "execute_command", + "tool_args": {"command": "nonexistent_command"}, + "tool_result": None, # Нет результата при ошибке + "agent_id": agent_id, + "agent_slug": agent_slug + } + } + + # Финальное событие с ошибкой в tool_log + end_event = { + "type": "agent.stream.end", + "data": { + "stream_id": stream_id, + "final_message": "I encountered an error while executing the command.", + "tool_log": [ + { + "name": "execute_command", + "args": {"command": "nonexistent_command"}, + "result": None, + "error": "Command not found: nonexistent_command" + } + ], + "agent_id": agent_id, + "agent_slug": agent_slug + } + } + + assert_stream_tool_structure(tool_event) + assert_stream_end_structure(end_event) + + # Проверяем что ошибка корректно записана + error_log = end_event["data"]["tool_log"][0] + assert error_log["error"] is not None + assert error_log["result"] is None + + except Exception as e: + pytest.fail(f"Tool error stream test failed: {e}") + + +@pytest.mark.asyncio +async def test_agent_stream_incremental_delta(agent_token: str, test_project: dict): + """Test agent streaming with incremental text deltas""" + uri = f"ws://localhost:8100/ws?token={agent_token}" + + try: + async with websockets.connect(uri, timeout=10) as websocket: + # Ждём аутентификацию + auth_response = await websocket.recv() + auth_data = json.loads(auth_response) + + agent_id = auth_data["data"]["member_id"] + agent_slug = auth_data["data"]["slug"] + stream_id = str(uuid.uuid4()) + + # Симулируем инкрементальное написание текста + text_deltas = [ + "I need to ", + "analyze this ", + "problem carefully. ", + "Let me start by ", + "examining the requirements..." + ] + + full_text = "" + + for delta in text_deltas: + full_text += delta + + delta_event = { + "type": "agent.stream.delta", + "data": { + "stream_id": stream_id, + "delta": delta, + "agent_id": agent_id, + "agent_slug": agent_slug + } + } + + assert_stream_delta_structure(delta_event) + assert delta_event["data"]["delta"] == delta + + # Финальное сообщение должно содержать полный текст + end_event = { + "type": "agent.stream.end", + "data": { + "stream_id": stream_id, + "final_message": full_text, + "tool_log": [], + "agent_id": agent_id, + "agent_slug": agent_slug + } + } + + assert_stream_end_structure(end_event) + assert end_event["data"]["final_message"] == full_text + + except Exception as e: + pytest.fail(f"Incremental delta stream test failed: {e}") + + +@pytest.mark.asyncio +async def test_agent_stream_ids_consistency(agent_token: str, test_project: dict): + """Test that stream_id is consistent across all events in one stream""" + uri = f"ws://localhost:8100/ws?token={agent_token}" + + try: + async with websockets.connect(uri, timeout=10) as websocket: + # Ждём аутентификацию + auth_response = await websocket.recv() + auth_data = json.loads(auth_response) + + agent_id = auth_data["data"]["member_id"] + agent_slug = auth_data["data"]["slug"] + stream_id = str(uuid.uuid4()) + + # Все события должны иметь одинаковый stream_id + events = [ + { + "type": "agent.stream.start", + "data": { + "stream_id": stream_id, + "project_id": test_project["id"], + "chat_id": test_project["chat_id"], + "task_id": None, + "agent_id": agent_id, + "agent_slug": agent_slug + } + }, + { + "type": "agent.stream.delta", + "data": { + "stream_id": stream_id, + "delta": "Processing...", + "agent_id": agent_id, + "agent_slug": agent_slug + } + }, + { + "type": "agent.stream.tool", + "data": { + "stream_id": stream_id, + "tool_name": "test_tool", + "tool_args": {}, + "tool_result": {"status": "ok"}, + "agent_id": agent_id, + "agent_slug": agent_slug + } + }, + { + "type": "agent.stream.end", + "data": { + "stream_id": stream_id, + "final_message": "Complete", + "tool_log": [], + "agent_id": agent_id, + "agent_slug": agent_slug + } + } + ] + + # Проверяем что все события имеют одинаковый stream_id + for event in events: + assert event["data"]["stream_id"] == stream_id + assert event["data"]["agent_id"] == agent_id + assert event["data"]["agent_slug"] == agent_slug + + except Exception as e: + pytest.fail(f"Stream ID consistency test failed: {e}") + + +@pytest.mark.asyncio +async def test_agent_config_updated_event(agent_token: str): + """Test config.updated event structure""" + uri = f"ws://localhost:8100/ws?token={agent_token}" + + try: + async with websockets.connect(uri, timeout=10) as websocket: + # Ждём аутентификацию + auth_response = await websocket.recv() + auth_data = json.loads(auth_response) + + # Симулируем событие обновления конфигурации + config_event = { + "type": "config.updated", + "data": { + "model": "gpt-4", + "provider": "openai", + "prompt": "Updated system prompt", + "chat_listen": "mentions", + "task_listen": "assigned", + "max_concurrent_tasks": 3, + "capabilities": ["coding", "testing", "documentation"], + "labels": ["backend", "python", "api", "database"] + } + } + + # Проверяем структуру события + assert config_event["type"] == "config.updated" + assert "data" in config_event + + config_data = config_event["data"] + assert "chat_listen" in config_data + assert "task_listen" in config_data + assert "capabilities" in config_data + assert "labels" in config_data + assert "max_concurrent_tasks" in config_data + + assert config_data["chat_listen"] in ["all", "mentions", "none"] + assert config_data["task_listen"] in ["all", "mentions", "assigned", "none"] + assert isinstance(config_data["capabilities"], list) + assert isinstance(config_data["labels"], list) + assert isinstance(config_data["max_concurrent_tasks"], int) + assert config_data["max_concurrent_tasks"] > 0 + + except Exception as e: + pytest.fail(f"Config updated event test failed: {e}") \ No newline at end of file diff --git a/tests/test_tasks.py b/tests/test_tasks.py new file mode 100644 index 0000000..5fe5d1a --- /dev/null +++ b/tests/test_tasks.py @@ -0,0 +1,487 @@ +""" +Тесты работы с задачами - CRUD операции, статусы, назначения, этапы, зависимости +""" +import pytest +import httpx +import uuid +from conftest import assert_uuid, assert_timestamp + + +@pytest.mark.asyncio +async def test_get_tasks_list(http_client: httpx.AsyncClient): + """Test getting list of all tasks""" + response = await http_client.get("/tasks") + assert response.status_code == 200 + + tasks = response.json() + assert isinstance(tasks, list) + + +@pytest.mark.asyncio +async def test_get_tasks_by_project(http_client: httpx.AsyncClient, test_project: dict, test_task: dict): + """Test filtering tasks by project""" + response = await http_client.get(f"/tasks?project_id={test_project['id']}") + assert response.status_code == 200 + + tasks = response.json() + assert isinstance(tasks, list) + + # Все задачи должны быть из нашего проекта + for task in tasks: + assert task["project"]["id"] == test_project["id"] + + # Наша тестовая задача должна быть в списке + task_ids = [t["id"] for t in tasks] + assert test_task["id"] in task_ids + + +@pytest.mark.asyncio +async def test_get_task_by_id(http_client: httpx.AsyncClient, test_task: dict): + """Test getting specific task by ID""" + response = await http_client.get(f"/tasks/{test_task['id']}") + assert response.status_code == 200 + + task = response.json() + assert task["id"] == test_task["id"] + assert task["title"] == test_task["title"] + assert task["description"] == test_task["description"] + assert task["status"] == test_task["status"] + + # Проверяем структуру + assert "project" in task + assert "number" in task + assert "key" in task + assert "type" in task + assert "priority" in task + assert "labels" in task + assert "assignee" in task + assert "reviewer" in task + assert "subtasks" in task + assert "steps" in task + assert "watcher_ids" in task + assert "depends_on" in task + assert "position" in task + assert "created_at" in task + assert "updated_at" in task + + +@pytest.mark.asyncio +async def test_get_task_not_found(http_client: httpx.AsyncClient): + """Test getting non-existent task returns 404""" + fake_id = str(uuid.uuid4()) + response = await http_client.get(f"/tasks/{fake_id}") + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_create_task(http_client: httpx.AsyncClient, test_project: dict): + """Test creating new task""" + task_data = { + "title": "New Test Task", + "description": "Task created via API test", + "type": "task", + "status": "backlog", + "priority": "high", + "labels": ["backend", "urgent"] + } + + response = await http_client.post(f"/tasks?project_id={test_project['id']}", json=task_data) + assert response.status_code == 201 + + task = response.json() + assert task["title"] == task_data["title"] + assert task["description"] == task_data["description"] + assert task["type"] == task_data["type"] + assert task["status"] == task_data["status"] + assert task["priority"] == task_data["priority"] + assert task["labels"] == task_data["labels"] + + # Проверяем автогенерируемые поля + assert_uuid(task["id"]) + assert isinstance(task["number"], int) + assert task["number"] > 0 + assert task["key"].startswith(test_project["slug"].upper()) + assert f"-{task['number']}" in task["key"] + + # Проверяем связанный проект + assert task["project"]["id"] == test_project["id"] + assert task["project"]["slug"] == test_project["slug"] + + # По умолчанию assignee и reviewer должны быть null + assert task["assignee"] is None + assert task["reviewer"] is None + + # Массивы должны быть пустыми + assert task["subtasks"] == [] + assert task["steps"] == [] + assert task["watcher_ids"] == [] + assert task["depends_on"] == [] + + +@pytest.mark.asyncio +async def test_create_task_minimal(http_client: httpx.AsyncClient, test_project: dict): + """Test creating task with minimal required data""" + task_data = {"title": "Minimal Task"} + + response = await http_client.post(f"/tasks?project_id={test_project['id']}", json=task_data) + assert response.status_code == 201 + + task = response.json() + assert task["title"] == "Minimal Task" + assert task["type"] == "task" # По умолчанию + assert task["status"] == "backlog" # По умолчанию + assert task["priority"] == "medium" # По умолчанию + + +@pytest.mark.asyncio +async def test_create_task_with_assignee(http_client: httpx.AsyncClient, test_project: dict, test_user: dict): + """Test creating task with assignee""" + task_data = { + "title": "Assigned Task", + "assignee_id": test_user["id"] + } + + response = await http_client.post(f"/tasks?project_id={test_project['id']}", json=task_data) + assert response.status_code == 201 + + task = response.json() + assert task["assignee"]["id"] == test_user["id"] + assert task["assignee"]["slug"] == test_user["slug"] + assert task["assignee"]["name"] == test_user["name"] + + +@pytest.mark.asyncio +async def test_create_task_without_project_fails(http_client: httpx.AsyncClient): + """Test creating task without project_id fails""" + task_data = {"title": "Task without project"} + + response = await http_client.post("/tasks", json=task_data) + assert response.status_code == 422 # Missing query parameter + + +@pytest.mark.asyncio +async def test_update_task(http_client: httpx.AsyncClient, test_task: dict): + """Test updating task information""" + update_data = { + "title": "Updated Task Title", + "description": "Updated description", + "status": "in_progress", + "priority": "high", + "labels": ["updated", "test"] + } + + response = await http_client.patch(f"/tasks/{test_task['id']}", json=update_data) + assert response.status_code == 200 + + task = response.json() + assert task["title"] == "Updated Task Title" + assert task["description"] == "Updated description" + assert task["status"] == "in_progress" + assert task["priority"] == "high" + assert task["labels"] == ["updated", "test"] + assert task["id"] == test_task["id"] + + +@pytest.mark.asyncio +async def test_assign_task(http_client: httpx.AsyncClient, test_task: dict, test_user: dict): + """Test assigning task to user""" + response = await http_client.post(f"/tasks/{test_task['id']}/assign", json={ + "assignee_id": test_user["id"] + }) + assert response.status_code == 200 + + task = response.json() + assert task["assignee"]["id"] == test_user["id"] + assert task["assignee"]["slug"] == test_user["slug"] + + +@pytest.mark.asyncio +async def test_assign_task_nonexistent_user(http_client: httpx.AsyncClient, test_task: dict): + """Test assigning task to non-existent user returns 404""" + fake_user_id = str(uuid.uuid4()) + response = await http_client.post(f"/tasks/{test_task['id']}/assign", json={ + "assignee_id": fake_user_id + }) + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_take_task_by_agent(agent_client: httpx.AsyncClient, test_project: dict): + """Test agent taking unassigned task""" + # Создаём неназначенную задачу + task_data = { + "title": "Task for Agent", + "status": "todo" # Подходящий статус для взятия + } + + response = await agent_client.post(f"/tasks?project_id={test_project['id']}", json=task_data) + assert response.status_code == 201 + task = response.json() + + # Берём задачу в работу + response = await agent_client.post(f"/tasks/{task['id']}/take") + assert response.status_code == 200 + + updated_task = response.json() + assert updated_task["status"] == "in_progress" + # assignee_id должен быть установлен на текущего агента + assert updated_task["assignee"] is not None + + +@pytest.mark.asyncio +async def test_reject_assigned_task(agent_client: httpx.AsyncClient, test_project: dict): + """Test agent rejecting assigned task""" + # Создаём задачу и назначаем её на агента + task_data = { + "title": "Task to Reject", + "status": "in_progress" + } + + response = await agent_client.post(f"/tasks?project_id={test_project['id']}", json=task_data) + assert response.status_code == 201 + task = response.json() + + # Отклоняем задачу + response = await agent_client.post(f"/tasks/{task['id']}/reject", json={ + "reason": "Too complex for my current capabilities" + }) + assert response.status_code == 200 + + data = response.json() + assert data["ok"] is True + assert data["reason"] == "Too complex for my current capabilities" + + +@pytest.mark.asyncio +async def test_add_task_watcher(http_client: httpx.AsyncClient, test_task: dict): + """Test adding watcher to task""" + response = await http_client.post(f"/tasks/{test_task['id']}/watch") + assert response.status_code == 200 + + data = response.json() + assert data["ok"] is True + assert isinstance(data["watcher_ids"], list) + + +@pytest.mark.asyncio +async def test_remove_task_watcher(http_client: httpx.AsyncClient, test_task: dict): + """Test removing watcher from task""" + # Сначала добавляем себя как наблюдателя + await http_client.post(f"/tasks/{test_task['id']}/watch") + + # Затем удаляем + response = await http_client.delete(f"/tasks/{test_task['id']}/watch") + assert response.status_code == 200 + + data = response.json() + assert data["ok"] is True + assert isinstance(data["watcher_ids"], list) + + +@pytest.mark.asyncio +async def test_filter_tasks_by_status(http_client: httpx.AsyncClient, test_project: dict): + """Test filtering tasks by status""" + # Создаём задачи с разными статусами + statuses = ["backlog", "todo", "in_progress", "done"] + created_tasks = [] + + for status in statuses: + task_data = { + "title": f"Task {status}", + "status": status + } + response = await http_client.post(f"/tasks?project_id={test_project['id']}", json=task_data) + assert response.status_code == 201 + created_tasks.append(response.json()) + + # Фильтруем по статусу "in_progress" + response = await http_client.get(f"/tasks?project_id={test_project['id']}&status=in_progress") + assert response.status_code == 200 + + tasks = response.json() + for task in tasks: + assert task["status"] == "in_progress" + + +@pytest.mark.asyncio +async def test_filter_tasks_by_assignee(http_client: httpx.AsyncClient, test_project: dict, test_user: dict): + """Test filtering tasks by assignee""" + # Создаём задачу и назначаем её на пользователя + task_data = { + "title": "Task for specific user", + "assignee_id": test_user["id"] + } + + response = await http_client.post(f"/tasks?project_id={test_project['id']}", json=task_data) + assert response.status_code == 201 + task = response.json() + + # Фильтруем по исполнителю + response = await http_client.get(f"/tasks?assignee_id={test_user['id']}") + assert response.status_code == 200 + + tasks = response.json() + for task in tasks: + assert task["assignee"]["id"] == test_user["id"] + + +@pytest.mark.asyncio +async def test_search_tasks_by_number(http_client: httpx.AsyncClient, test_task: dict): + """Test searching tasks by task number""" + task_key = test_task["key"] + + response = await http_client.get(f"/tasks?q={task_key}") + assert response.status_code == 200 + + tasks = response.json() + task_keys = [t["key"] for t in tasks] + assert task_key in task_keys + + +@pytest.mark.asyncio +async def test_search_tasks_by_title(http_client: httpx.AsyncClient, test_task: dict): + """Test searching tasks by title""" + search_term = test_task["title"].split()[0] # Первое слово из заголовка + + response = await http_client.get(f"/tasks?q={search_term}") + assert response.status_code == 200 + + tasks = response.json() + # Хотя бы одна задача должна содержать искомое слово + found = any(search_term.lower() in task["title"].lower() for task in tasks) + assert found + + +@pytest.mark.asyncio +async def test_delete_task(http_client: httpx.AsyncClient, test_project: dict): + """Test deleting task""" + # Создаём временную задачу для удаления + task_data = {"title": "Task to Delete"} + response = await http_client.post(f"/tasks?project_id={test_project['id']}", json=task_data) + assert response.status_code == 201 + task = response.json() + + # Удаляем задачу + response = await http_client.delete(f"/tasks/{task['id']}") + assert response.status_code == 200 + + data = response.json() + assert data["ok"] is True + + # Проверяем что задача действительно удалена + response = await http_client.get(f"/tasks/{task['id']}") + assert response.status_code == 404 + + +# Тесты этапов (Steps) + +@pytest.mark.asyncio +async def test_get_task_steps(http_client: httpx.AsyncClient, test_task: dict): + """Test getting task steps list""" + response = await http_client.get(f"/tasks/{test_task['id']}/steps") + assert response.status_code == 200 + + steps = response.json() + assert isinstance(steps, list) + + +@pytest.mark.asyncio +async def test_create_task_step(http_client: httpx.AsyncClient, test_task: dict): + """Test creating new task step""" + step_data = {"title": "Complete step 1"} + + response = await http_client.post(f"/tasks/{test_task['id']}/steps", json=step_data) + assert response.status_code == 201 + + step = response.json() + assert step["title"] == "Complete step 1" + assert step["done"] is False # По умолчанию + assert isinstance(step["position"], int) + assert_uuid(step["id"]) + + +@pytest.mark.asyncio +async def test_update_task_step(http_client: httpx.AsyncClient, test_task: dict): + """Test updating task step""" + # Создаём этап + step_data = {"title": "Step to update"} + response = await http_client.post(f"/tasks/{test_task['id']}/steps", json=step_data) + assert response.status_code == 201 + step = response.json() + + # Обновляем этап + update_data = { + "title": "Updated step title", + "done": True + } + + response = await http_client.patch(f"/tasks/{test_task['id']}/steps/{step['id']}", json=update_data) + assert response.status_code == 200 + + updated_step = response.json() + assert updated_step["title"] == "Updated step title" + assert updated_step["done"] is True + assert updated_step["id"] == step["id"] + + +@pytest.mark.asyncio +async def test_delete_task_step(http_client: httpx.AsyncClient, test_task: dict): + """Test deleting task step""" + # Создаём этап + step_data = {"title": "Step to delete"} + response = await http_client.post(f"/tasks/{test_task['id']}/steps", json=step_data) + assert response.status_code == 201 + step = response.json() + + # Удаляем этап + response = await http_client.delete(f"/tasks/{test_task['id']}/steps/{step['id']}") + assert response.status_code == 200 + + data = response.json() + assert data["ok"] is True + + +# Тесты связей между задачами + +@pytest.mark.asyncio +async def test_create_task_link(http_client: httpx.AsyncClient, test_project: dict): + """Test creating dependency between tasks""" + # Создаём две задачи + task1_data = {"title": "Source Task"} + response = await http_client.post(f"/tasks?project_id={test_project['id']}", json=task1_data) + assert response.status_code == 201 + task1 = response.json() + + task2_data = {"title": "Target Task"} + response = await http_client.post(f"/tasks?project_id={test_project['id']}", json=task2_data) + assert response.status_code == 201 + task2 = response.json() + + # Создаём связь "task1 зависит от task2" + link_data = { + "target_id": task2["id"], + "link_type": "depends_on" + } + + response = await http_client.post(f"/tasks/{task1['id']}/links", json=link_data) + assert response.status_code == 201 + + link = response.json() + assert link["source_id"] == task1["id"] + assert link["target_id"] == task2["id"] + assert link["link_type"] == "depends_on" + assert link["target_key"] == task2["key"] + assert link["source_key"] == task1["key"] + assert_uuid(link["id"]) + + +@pytest.mark.asyncio +async def test_create_task_link_self_reference_fails(http_client: httpx.AsyncClient, test_task: dict): + """Test that linking task to itself returns 400""" + link_data = { + "target_id": test_task["id"], # Ссылка на себя + "link_type": "depends_on" + } + + response = await http_client.post(f"/tasks/{test_task['id']}/links", json=link_data) + assert response.status_code == 400 \ No newline at end of file diff --git a/tests/test_websocket.py b/tests/test_websocket.py new file mode 100644 index 0000000..81b4e94 --- /dev/null +++ b/tests/test_websocket.py @@ -0,0 +1,411 @@ +""" +Тесты WebSocket подключения и получения событий +""" +import pytest +import asyncio +import websockets +import json +import uuid +from conftest import assert_uuid + + +@pytest.mark.asyncio +async def test_websocket_auth_with_jwt(admin_token: str): + """Test WebSocket authentication using JWT token""" + uri = f"ws://localhost:8100/ws?token={admin_token}" + + try: + async with websockets.connect(uri, timeout=10) as websocket: + # Ожидаем сообщение auth.ok + response = await websocket.recv() + auth_response = json.loads(response) + + assert auth_response["type"] == "auth.ok" + assert "data" in auth_response + + auth_data = auth_response["data"] + assert "member_id" in auth_data + assert "slug" in auth_data + assert "name" in auth_data + assert "lobby_chat_id" in auth_data + assert "projects" in auth_data + assert "online" in auth_data + + assert_uuid(auth_data["member_id"]) + assert auth_data["slug"] == "admin" + assert_uuid(auth_data["lobby_chat_id"]) + assert isinstance(auth_data["projects"], list) + assert isinstance(auth_data["online"], list) + + except Exception as e: + pytest.fail(f"WebSocket connection failed: {e}") + + +@pytest.mark.asyncio +async def test_websocket_auth_with_agent_token(agent_token: str): + """Test WebSocket authentication using agent token""" + uri = f"ws://localhost:8100/ws?token={agent_token}" + + try: + async with websockets.connect(uri, timeout=10) as websocket: + # Ожидаем сообщение auth.ok + response = await websocket.recv() + auth_response = json.loads(response) + + assert auth_response["type"] == "auth.ok" + auth_data = auth_response["data"] + + # У агента должна быть конфигурация + assert "agent_config" in auth_data + assert "assigned_tasks" in auth_data + + agent_config = auth_data["agent_config"] + assert "chat_listen" in agent_config + assert "task_listen" in agent_config + assert "capabilities" in agent_config + assert "labels" in agent_config + + assert isinstance(auth_data["assigned_tasks"], list) + + except Exception as e: + pytest.fail(f"WebSocket connection failed: {e}") + + +@pytest.mark.asyncio +async def test_websocket_auth_first_message(admin_token: str): + """Test WebSocket authentication by sending auth message first""" + uri = "ws://localhost:8100/ws" + + try: + async with websockets.connect(uri, timeout=10) as websocket: + # Отправляем сообщение аутентификации + auth_message = { + "type": "auth", + "token": admin_token + } + await websocket.send(json.dumps(auth_message)) + + # Ожидаем ответ + response = await websocket.recv() + auth_response = json.loads(response) + + assert auth_response["type"] == "auth.ok" + assert "data" in auth_response + + except Exception as e: + pytest.fail(f"WebSocket auth message failed: {e}") + + +@pytest.mark.asyncio +async def test_websocket_auth_invalid_token(): + """Test WebSocket authentication with invalid token""" + uri = "ws://localhost:8100/ws?token=invalid_token" + + try: + async with websockets.connect(uri, timeout=10) as websocket: + # Ожидаем сообщение об ошибке + response = await websocket.recv() + auth_response = json.loads(response) + + assert auth_response["type"] == "auth.error" + assert "message" in auth_response + + except websockets.exceptions.ConnectionClosedError: + # Соединение может быть закрыто сразу при неверном токене + pass + except Exception as e: + pytest.fail(f"Unexpected error: {e}") + + +@pytest.mark.asyncio +async def test_websocket_heartbeat(admin_token: str): + """Test WebSocket heartbeat mechanism""" + uri = f"ws://localhost:8100/ws?token={admin_token}" + + try: + async with websockets.connect(uri, timeout=10) as websocket: + # Ждём аутентификацию + await websocket.recv() # auth.ok + + # Отправляем heartbeat + heartbeat = {"type": "heartbeat"} + await websocket.send(json.dumps(heartbeat)) + + # Heartbeat может не иметь ответа, но соединение должно остаться живым + await asyncio.sleep(0.5) + + # Проверяем что соединение живо отправкой ещё одного heartbeat + heartbeat_with_status = { + "type": "heartbeat", + "status": "online" + } + await websocket.send(json.dumps(heartbeat_with_status)) + + except Exception as e: + pytest.fail(f"Heartbeat test failed: {e}") + + +@pytest.mark.asyncio +async def test_websocket_project_subscription(admin_token: str, test_project: dict): + """Test subscribing to project events via WebSocket""" + uri = f"ws://localhost:8100/ws?token={admin_token}" + + try: + async with websockets.connect(uri, timeout=10) as websocket: + # Ждём аутентификацию + await websocket.recv() # auth.ok + + # Подписываемся на проект + subscribe = { + "type": "project.subscribe", + "project_id": test_project["id"] + } + await websocket.send(json.dumps(subscribe)) + + # ACK может прийти или не прийти, это зависит от реализации + await asyncio.sleep(0.5) + + # Отписываемся от проекта + unsubscribe = { + "type": "project.unsubscribe", + "project_id": test_project["id"] + } + await websocket.send(json.dumps(unsubscribe)) + + except Exception as e: + pytest.fail(f"Project subscription test failed: {e}") + + +@pytest.mark.asyncio +async def test_websocket_send_chat_message(admin_token: str, test_project: dict): + """Test sending chat message via WebSocket""" + uri = f"ws://localhost:8100/ws?token={admin_token}" + + try: + async with websockets.connect(uri, timeout=10) as websocket: + # Ждём аутентификацию + await websocket.recv() # auth.ok + + # Отправляем сообщение в чат проекта + message = { + "type": "chat.send", + "chat_id": test_project["chat_id"], + "content": "Test message via WebSocket" + } + await websocket.send(json.dumps(message)) + + # Ожидаем получить обратно событие message.new + await asyncio.wait_for( + receive_message_new_event(websocket), + timeout=5.0 + ) + + except asyncio.TimeoutError: + pytest.fail("Timeout waiting for message.new event") + except Exception as e: + pytest.fail(f"Chat message test failed: {e}") + + +async def receive_message_new_event(websocket): + """Helper function to receive message.new event""" + while True: + response = await websocket.recv() + event = json.loads(response) + + if event["type"] == "message.new": + assert "data" in event + message_data = event["data"] + + assert "id" in message_data + assert "content" in message_data + assert "author" in message_data + assert "created_at" in message_data + + assert_uuid(message_data["id"]) + return event + + +@pytest.mark.asyncio +async def test_websocket_send_task_comment(admin_token: str, test_task: dict): + """Test sending task comment via WebSocket""" + uri = f"ws://localhost:8100/ws?token={admin_token}" + + try: + async with websockets.connect(uri, timeout=10) as websocket: + # Ждём аутентификацию + await websocket.recv() # auth.ok + + # Отправляем комментарий к задаче + message = { + "type": "chat.send", + "task_id": test_task["id"], + "content": "Task comment via WebSocket" + } + await websocket.send(json.dumps(message)) + + # Ожидаем получить обратно событие message.new + await asyncio.wait_for( + receive_message_new_event(websocket), + timeout=5.0 + ) + + except asyncio.TimeoutError: + pytest.fail("Timeout waiting for task comment event") + except Exception as e: + pytest.fail(f"Task comment test failed: {e}") + + +@pytest.mark.asyncio +async def test_websocket_agent_with_thinking(agent_token: str, test_project: dict): + """Test agent sending message with thinking via WebSocket""" + uri = f"ws://localhost:8100/ws?token={agent_token}" + + try: + async with websockets.connect(uri, timeout=10) as websocket: + # Ждём аутентификацию + await websocket.recv() # auth.ok + + # Отправляем сообщение с thinking (только агент может) + message = { + "type": "chat.send", + "chat_id": test_project["chat_id"], + "content": "Agent message with thinking", + "thinking": "Let me think about this problem..." + } + await websocket.send(json.dumps(message)) + + # Ожидаем получить обратно событие message.new + event = await asyncio.wait_for( + receive_message_new_event(websocket), + timeout=5.0 + ) + + # Проверяем что thinking присутствует + message_data = event["data"] + assert "thinking" in message_data + assert message_data["thinking"] == "Let me think about this problem..." + assert message_data["author_type"] == "agent" + + except asyncio.TimeoutError: + pytest.fail("Timeout waiting for agent message event") + except Exception as e: + pytest.fail(f"Agent message test failed: {e}") + + +@pytest.mark.asyncio +async def test_websocket_message_with_mentions(admin_token: str, test_project: dict, test_user: dict): + """Test sending message with mentions via WebSocket""" + uri = f"ws://localhost:8100/ws?token={admin_token}" + + try: + async with websockets.connect(uri, timeout=10) as websocket: + # Ждём аутентификацию + await websocket.recv() # auth.ok + + # Отправляем сообщение с упоминанием + message = { + "type": "chat.send", + "chat_id": test_project["chat_id"], + "content": f"Hey @{test_user['slug']}, check this!", + "mentions": [test_user["id"]] + } + await websocket.send(json.dumps(message)) + + # Ожидаем получить обратно событие message.new + event = await asyncio.wait_for( + receive_message_new_event(websocket), + timeout=5.0 + ) + + # Проверяем что mentions присутствуют + message_data = event["data"] + assert "mentions" in message_data + assert len(message_data["mentions"]) == 1 + assert message_data["mentions"][0]["id"] == test_user["id"] + + except asyncio.TimeoutError: + pytest.fail("Timeout waiting for message with mentions") + except Exception as e: + pytest.fail(f"Message with mentions test failed: {e}") + + +@pytest.mark.asyncio +async def test_websocket_invalid_message_format(admin_token: str): + """Test sending invalid message format via WebSocket""" + uri = f"ws://localhost:8100/ws?token={admin_token}" + + try: + async with websockets.connect(uri, timeout=10) as websocket: + # Ждём аутентификацию + await websocket.recv() # auth.ok + + # Отправляем невалидное сообщение + await websocket.send("invalid json") + + # Может прийти ошибка или соединение может закрыться + try: + response = await asyncio.wait_for(websocket.recv(), timeout=2.0) + error_event = json.loads(response) + assert error_event["type"] == "error" + except asyncio.TimeoutError: + # Таймаут тоже нормально - сервер может игнорировать невалидные сообщения + pass + + except websockets.exceptions.ConnectionClosed: + # Соединение может быть закрыто при невалидном сообщении + pass + except Exception as e: + pytest.fail(f"Invalid message test failed: {e}") + + +@pytest.mark.asyncio +async def test_websocket_connection_without_auth(): + """Test WebSocket connection without authentication""" + uri = "ws://localhost:8100/ws" + + try: + async with websockets.connect(uri, timeout=10) as websocket: + # Отправляем сообщение без аутентификации + message = { + "type": "chat.send", + "content": "Unauthorized message" + } + await websocket.send(json.dumps(message)) + + # Должна прийти ошибка аутентификации + response = await asyncio.wait_for(websocket.recv(), timeout=5.0) + error_event = json.loads(response) + + assert error_event["type"] == "auth.error" or error_event["type"] == "error" + + except websockets.exceptions.ConnectionClosed: + # Соединение может быть закрыто если требуется немедленная аутентификация + pass + except Exception as e: + pytest.fail(f"Unauthenticated connection test failed: {e}") + + +@pytest.mark.asyncio +async def test_websocket_multiple_connections(admin_token: str): + """Test multiple WebSocket connections for same user""" + uri = f"ws://localhost:8100/ws?token={admin_token}" + + try: + # Открываем два соединения одновременно + async with websockets.connect(uri, timeout=10) as ws1, \ + websockets.connect(uri, timeout=10) as ws2: + + # Ждём аутентификацию на обоих соединениях + await ws1.recv() # auth.ok + await ws2.recv() # auth.ok + + # Отправляем heartbeat в оба соединения + heartbeat = {"type": "heartbeat"} + await ws1.send(json.dumps(heartbeat)) + await ws2.send(json.dumps(heartbeat)) + + # Оба соединения должны остаться живыми + await asyncio.sleep(1) + + except Exception as e: + pytest.fail(f"Multiple connections test failed: {e}") \ No newline at end of file